From 455d8080f0f81698624593166402ed3ec562f1d2 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 13:59:54 +0000 Subject: [PATCH 1/8] Prioritize visible Grid atlas pages --- apps/desktop/e2e/gpu.spec.ts | 196 +++++++-- apps/desktop/src/main/docs/DOCS.md | 2 +- .../src/main/workspace/WorkspaceManager.ts | 1 - .../src/main/workspace/WorkspaceProcess.ts | 12 - .../components/home/GlyphCatalogCanvas.tsx | 4 +- .../components/home/GlyphCatalogController.ts | 403 +++++++++++++----- .../src/components/home/GlyphGrid.tsx | 20 +- .../home/GlyphPreviewLayout.test.ts | 7 + .../src/components/home/GlyphPreviewLayout.ts | 21 +- .../home/glyphCatalogLayout.test.ts | 95 +++-- .../src/components/home/glyphCatalogLayout.ts | 37 +- .../graphics/backends/ResidentGlyphLayer.ts | 76 +--- .../renderer/src/lib/graphics/docs/DOCS.md | 14 +- .../src/renderer/src/types/glyphCatalog.ts | 19 +- apps/desktop/src/shared/workspace/protocol.ts | 1 - .../utility/workspace/WorkspaceHost.test.ts | 1 - .../src/utility/workspace/WorkspaceHost.ts | 2 - crates/shift-bridge/docs/DOCS.md | 10 +- crates/shift-bridge/index.d.ts | 7 + crates/shift-bridge/src/bridge.rs | 15 + crates/shift-slug/docs/DOCS.md | 3 +- crates/shift-slug/src/lib.rs | 7 +- crates/shift-slug/src/resident.rs | 20 +- crates/shift-slug/src/variable.rs | 68 +++ crates/shift-slug/tests/atlas.rs | 33 ++ packages/types/src/bridge/generated.ts | 7 + packages/types/src/bridge/index.ts | 1 + packages/types/src/index.ts | 1 + 28 files changed, 792 insertions(+), 291 deletions(-) diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index 9c921b95..529807c0 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { ElectronApplication, Locator, Page } from "@playwright/test"; import { test, expect, navigateToEditor } from "./fixtures/perfApp"; const RESIDENT_GPU_ERROR = /resident glyph (device lost|frame failed|initialization failed)/i; @@ -297,47 +297,185 @@ test.describe("Resident catalog GPU", () => { expect(scrollDuration).toBeLessThan(1_000); }); - test("rebuilds the complete atlas after a source change", async ({ page }) => { - await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); + test("replaces the visible frame before completing a selected-source deletion", async ({ + electronApp, + page, + }) => { + const glyphCanvas = await preparePagedGrid(electronApp, page); + await trackGridTransitions(page); + await trackSlugAtlasLoads(page); + + await page.evaluate(async () => { + const workspace = window.shift; + const font = workspace?.font; + const source = font?.sources.find((candidate) => candidate.id !== font.defaultSource.id); + if (!workspace || !font || !source) throw new Error("Expected a non-default source"); + + workspace.editor.setDesignLocation( + new Map( + font + .getAxes() + .map((axis) => [axis.id, source.location.values[axis.id] ?? axis.default] as const), + ), + ); + font.deleteSource(source.id); + await font.editCoordinator.settled(); + }); + + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + const state = await observedGridState(page); + expect(state.readiness).toEqual(expect.arrayContaining(["Stale", "Visible", "Complete"])); + expect(state.readiness.indexOf("Stale")).toBeLessThan(state.readiness.indexOf("Visible")); + expect(state.readiness.indexOf("Visible")).toBeLessThan( + state.readiness.lastIndexOf("Complete"), + ); + expect(state.hiddenTransitions).toBe(0); + expect(state.patchRootCounts[0]).toBeLessThan(state.glyphCount); + expect(state.patchRootCounts.at(-1)).toBe(state.glyphCount); + }); + + test("replaces a non-default design location atomically after deleting its axis", async ({ + electronApp, + page, + }) => { + const glyphCanvas = await preparePagedGrid(electronApp, page); + await trackGridTransitions(page); + + const deletedAxis = await page.evaluate(async () => { + const workspace = window.shift; + const axis = workspace?.font.getAxes()[0]; + if (!workspace || !axis) throw new Error("Expected a variable axis"); + + const nonDefault = axis.maximum === axis.default ? axis.minimum : axis.maximum; + workspace.editor.setDesignLocation(new Map([[axis.id, nonDefault ?? axis.default]])); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + workspace.font.deleteAxis(axis.id); + await workspace.font.editCoordinator.settled(); + return axis.id; + }); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + expect( + await page.evaluate( + (axisId) => window.shift?.font.getAxes().some((axis) => axis.id === axisId), + deletedAxis, + ), + ).toBe(false); + const state = await observedGridState(page); + expect(state.readiness).toEqual(expect.arrayContaining(["Stale", "Visible", "Complete"])); + expect(state.readiness.indexOf("Stale")).toBeLessThan(state.readiness.indexOf("Visible")); + expect(state.readiness.indexOf("Visible")).toBeLessThan( + state.readiness.lastIndexOf("Complete"), + ); + expect(state.hiddenTransitions).toBe(0); + }); + + test("expands every preview cell for outlines outside the font metrics", async ({ page }) => { const scrollViewport = page.getByLabel("Glyph catalog"); - await scrollViewport.waitFor({ state: "visible" }); const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); - await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + const initialHeight = Number(await glyphCanvas.getAttribute("data-preview-height")); await navigateToEditor(page, "53"); - await trackSlugFrameSubmits(page); - await trackSlugAtlasLoads(page); await page.evaluate(async () => { - const font = window.shift?.font; - const source = font?.sources[0]; - if (!font || !source) throw new Error("Expected a source for global invalidation"); - - await font.updateSource({ ...source, name: `${source.name} E2E` }); - await font.editCoordinator.settled(); + const editor = window.shift?.editor; + if (!editor) throw new Error("Expected editor runtime"); + const inserted = editor.insertContent({ + contours: [ + { + closed: true, + points: [ + { x: -600, y: -800, pointType: "onCurve", smooth: false }, + { x: 1800, y: -800, pointType: "onCurve", smooth: false }, + { x: 1800, y: 1800, pointType: "onCurve", smooth: false }, + { x: -600, y: 1800, pointType: "onCurve", smooth: false }, + ], + }, + ], + }); + if (!inserted) throw new Error("Oversized contour insertion failed"); + await editor.font.editCoordinator.settled(); }); - - const returnStarted = performance.now(); await page.getByRole("button", { name: "Display all glyphs" }).click(); await page.waitForURL(/#\/home/); - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugFrameSubmits), { - timeout: 30_000, - }) - .toBe("1"); - await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); - const recoveryDuration = performance.now() - returnStarted; - const atlasLoads = await page.evaluate(() => ({ - complete: Number(document.documentElement.dataset.slugCompleteAtlasPrepares), - patches: document.documentElement.dataset.slugPatchRootCounts, - })); - console.log(`Resident catalog source recovery took ${recoveryDuration.toFixed(0)}ms`); - expect(recoveryDuration).toBeLessThan(1_000); - expect(atlasLoads).toEqual({ complete: 1, patches: "[]" }); + await expect + .poll(async () => Number(await glyphCanvas.getAttribute("data-preview-height"))) + .toBeGreaterThan(initialHeight); + await expect(glyphCanvas).toBeVisible(); + expect(Number(await glyphCanvas.getAttribute("data-preview-horizontal"))).toBeGreaterThan(0); }); }); +async function preparePagedGrid(electronApp: ElectronApplication, page: Page): Promise { + await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); + await electronApp.evaluate(async ({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.setSize(760, 500); + }); + + const scrollViewport = page.getByLabel("Glyph catalog"); + const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + return glyphCanvas; +} + +async function trackGridTransitions(page: Page): Promise { + await page.evaluate(() => { + const canvas = document.querySelector( + '[aria-label="Glyph catalog"] + canvas', + ); + if (!canvas) throw new Error("Expected resident glyph canvas"); + + document.documentElement.dataset.gridReadinessTransitions = "[]"; + document.documentElement.dataset.gridHiddenTransitions = "0"; + new MutationObserver((records) => { + for (const record of records) { + if (record.attributeName === "data-grid-readiness") { + const transitions = JSON.parse( + document.documentElement.dataset.gridReadinessTransitions ?? "[]", + ) as string[]; + transitions.push(canvas.dataset.gridReadiness ?? ""); + document.documentElement.dataset.gridReadinessTransitions = JSON.stringify(transitions); + } + if (record.attributeName === "style" && canvas.style.visibility === "hidden") { + document.documentElement.dataset.gridHiddenTransitions = String( + Number(document.documentElement.dataset.gridHiddenTransitions) + 1, + ); + } + } + }).observe(canvas, { + attributeFilter: ["data-grid-readiness", "style"], + attributes: true, + }); + }); +} + +async function observedGridState(page: Page): Promise<{ + readiness: string[]; + hiddenTransitions: number; + patchRootCounts: number[]; + glyphCount: number; +}> { + return page.evaluate(() => ({ + readiness: JSON.parse( + document.documentElement.dataset.gridReadinessTransitions ?? "[]", + ) as string[], + hiddenTransitions: Number(document.documentElement.dataset.gridHiddenTransitions), + patchRootCounts: JSON.parse( + document.documentElement.dataset.slugPatchRootCounts ?? "[]", + ) as number[], + glyphCount: window.shift?.font.glyphRecords().length ?? 0, + })); +} + async function trackSlugFrameSubmits(page: Page): Promise { await page.evaluate(() => { const originalSubmit = GPUQueue.prototype.submit; diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 55b3eefa..898790f2 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -62,7 +62,7 @@ On macOS, closing the last window leaves Shift running. A later Dock activation File -> New asks `WorkspaceManager.createUntitled()` for a session. The launcher prepares an idle utility process, so File -> Open overlaps process startup with `showOpenFontDialog()` before asking `WorkspaceManager.openPath(path)`. -For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. After main receives the open response, it sends a fire-and-forget complete authored glyph compilation request so layer acquisition and Slug work overlap workspace-window and WebGPU startup; the grid still waits for the complete atlas and never renders placeholder glyphs. +For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. Main does not start monolithic Slug preparation: the renderer requests the current visible root page first, presents that complete frame, and cooperatively fills fixed-size catalog pages afterward. ### Window Attachment diff --git a/apps/desktop/src/main/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts index f3c1a032..e3bbd2fa 100644 --- a/apps/desktop/src/main/workspace/WorkspaceManager.ts +++ b/apps/desktop/src/main/workspace/WorkspaceManager.ts @@ -92,7 +92,6 @@ export class WorkspaceManager { return existingAfterOpen; } - workspaceProcess.prepareAuthoredGlyphCompilation(); return this.#registerLoadedSession(workspaceProcess, state); } catch (error) { workspaceProcess.stop(); diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index c05eda21..d3d0073d 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -124,18 +124,6 @@ export class WorkspaceProcess { return this.#requireChannel().call("workspace.open", request); } - /** Starts complete authored glyph compilation without delaying workspace-window creation. */ - prepareAuthoredGlyphCompilation(): void { - const preparation = this.#requireChannel().call( - "workspace.prepareAuthoredGlyphCompilation", - undefined, - ); - // This is a genuine fire-and-forget boundary: renderer startup consumes the result later. - void preparation.catch((error: unknown) => { - this.#log.error("authored glyph compilation failed", error); - }); - } - /** * Closes the live utility-owned workspace, treating an unavailable process as already closed. * diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx b/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx index c0490762..34bb4b1e 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx @@ -17,6 +17,7 @@ export function GlyphCatalogCanvas({ openGlyph, onFirstFrame, onUnavailable, + onPreviewExtentsChange, }: GlyphCatalogCanvasProps) { const editor = useEditor(); const { themeName } = useTheme(); @@ -50,6 +51,7 @@ export function GlyphCatalogCanvas({ if (nextReady) onFirstFrame(); }, onUnavailable, + onPreviewExtentsChange, ); controllerRef.current = controller; @@ -57,7 +59,7 @@ export function GlyphCatalogCanvas({ controllerRef.current = null; controller.destroy(); }; - }, [containerRef, editor.font, onFirstFrame, onUnavailable, openGlyph]); + }, [containerRef, editor.font, onFirstFrame, onPreviewExtentsChange, onUnavailable, openGlyph]); useLayoutEffect(() => { controllerRef.current?.update( diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 2751e7d7..39919147 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -1,5 +1,5 @@ import type { Point2D } from "@shift/geo"; -import type { GlyphId } from "@shift/types"; +import type { GlyphId, SlugPreviewExtents } from "@shift/types"; import { GlyphPreviewLayout } from "./GlyphPreviewLayout"; import { GlyphCatalogLayout } from "./glyphCatalogLayout"; import { GlyphCatalogOverlay } from "./GlyphCatalogOverlay"; @@ -14,10 +14,19 @@ import type { GlyphCatalogControllerFrame, GlyphCatalogFrame, GlyphCatalogItem, + GridFrame, + GridReadiness, } from "@/types/glyphCatalog"; import type { GlyphPreviewInstance } from "@/types/glyphPreview"; -/** Owns catalog DOM events, frame scheduling, layout, and resident rendering. */ +const ATLAS_PAGE_ROOT_COUNT = 256; +const EMPTY_PREVIEW_EXTENTS: SlugPreviewExtents = { + horizontal: 0, + minimumY: 0, + maximumY: 0, +}; + +/** Owns catalog DOM events, visible-first atlas replacement, and frame scheduling. */ export class GlyphCatalogController { readonly #container: HTMLDivElement; readonly #glyphCanvas: HTMLCanvasElement; @@ -27,16 +36,21 @@ export class GlyphCatalogController { readonly #openGlyph: (glyph: GlyphCatalogItem) => Promise; readonly #onReadyChange: (ready: boolean) => void; readonly #onUnavailable: () => void; + readonly #onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void; readonly #overlay: GlyphCatalogOverlay; readonly #frames = new FrameHandler(); readonly #resizeObserver: ResizeObserver; readonly #fontEffect: Effect; readonly #invalidGlyphIds = new Set(); - #frame: GlyphCatalogControllerFrame | null = null; + #targetFrame: GridFrame | null = null; + #activeFrame: GridFrame | null = null; + #fontGlyphIds: readonly GlyphId[] = []; #layer: ResidentGlyphLayer | null = null; - /** Single native atlas operation; aborted work retains this slot until it actually settles. */ + /** Device initialization; aborted work retains this slot until it settles. */ #refresh: AbortController | null = null; + #visibleBuild: AbortController | null = null; + #completeBuild: AbortController | null = null; #pointer: Point2D | null = null; #hoveredCatalogIndex: number | null = null; #firstFrameStarted = false; @@ -53,6 +67,7 @@ export class GlyphCatalogController { openGlyph: (glyph: GlyphCatalogItem) => Promise, onReadyChange: (ready: boolean) => void, onUnavailable: () => void, + onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void, ) { this.#container = container; this.#glyphCanvas = glyphCanvas; @@ -62,14 +77,20 @@ export class GlyphCatalogController { this.#openGlyph = openGlyph; this.#onReadyChange = onReadyChange; this.#onUnavailable = onUnavailable; + this.#onPreviewExtentsChange = onPreviewExtentsChange; this.#overlay = new GlyphCatalogOverlay(overlayCanvas); this.#glyphCanvas.dataset.fullyResident = "false"; + this.#glyphCanvas.dataset.gridReadiness = "Initial" satisfies GridReadiness; - this.#resizeObserver = new ResizeObserver(() => this.redraw()); + this.#resizeObserver = new ResizeObserver(() => { + this.#needsRedraw = true; + void this.#refreshVisible(); + this.redraw(); + }); this.#resizeObserver.observe(container); container.addEventListener("scroll", this.#handleScroll, { passive: true }); container.addEventListener("pointermove", this.#handlePointerMove, { passive: true }); - container.addEventListener("pointerleave", this.#handlePointerLeave, { passive: true }); + container.addEventListener("pointerleave", this.#handlePointerLeave); container.addEventListener("click", this.#handleClick); document.fonts.addEventListener("loadingdone", this.#handleFontsLoaded); void this.#redrawWhenFontsReady(); @@ -86,29 +107,24 @@ export class GlyphCatalogController { } update(frame: GlyphCatalogControllerFrame, inputContainer: HTMLDivElement | null): void { - const previous = this.#frame; + const previousTarget = this.#targetFrame; + const previewExtents = previousTarget?.previewExtents ?? + this.#activeFrame?.previewExtents ?? { ...EMPTY_PREVIEW_EXTENTS }; + this.#targetFrame = { ...frame, previewExtents }; + if ( - !previous || - previous.glyphs !== frame.glyphs || - previous.location !== frame.location || - previous.axes !== frame.axes || - previous.metrics !== frame.metrics || - previous.sourceId !== frame.sourceId || - previous.themeName !== frame.themeName + !previousTarget || + previousTarget.glyphs !== frame.glyphs || + previousTarget.location !== frame.location || + previousTarget.axes !== frame.axes || + previousTarget.metrics !== frame.metrics || + previousTarget.sourceId !== frame.sourceId || + previousTarget.themeName !== frame.themeName ) { this.#needsRedraw = true; } - if (previous?.active === false && frame.active) { - this.#firstFrameStarted = false; - this.#needsRedraw = true; - this.#onReadyChange(false); - } - - this.#frame = frame; - if (previous?.glyphs !== frame.glyphs) this.#updateFullyResident(); this.#overlay.setInputContainer(inputContainer); - if (!frame.active) { this.#frames.cancelUpdate(); this.#pointer = null; @@ -122,7 +138,7 @@ export class GlyphCatalogController { } redraw(): void { - if (this.#disposed || !this.#frame?.active) return; + if (this.#disposed || !this.#targetFrame?.active) return; this.#frames.requestUpdate(() => this.#draw()); } @@ -131,6 +147,8 @@ export class GlyphCatalogController { this.#disposed = true; this.#fontEffect.dispose(); this.#refresh?.abort(new Error("glyph catalog disposed")); + this.#visibleBuild?.abort(new Error("glyph catalog disposed")); + this.#completeBuild?.abort(new Error("glyph catalog disposed")); this.#layer?.destroy(); this.#layer = null; this.#frames.cancelUpdate(); @@ -145,33 +163,31 @@ export class GlyphCatalogController { } #invalidate(glyphIds: readonly GlyphId[] | null, fontGlyphIds: readonly GlyphId[]): void { - if (glyphIds === null) { - this.#invalidGlyphIds.clear(); - this.#refresh?.abort(new Error("resident font changed")); - this.#layer?.destroy(); - this.#layer = null; - this.#glyphCanvas.dataset.fullyResident = "false"; - this.#firstFrameStarted = false; - this.#needsRedraw = true; - this.#onReadyChange(false); + const directoryChanged = !sameGlyphIds(this.#fontGlyphIds, fontGlyphIds); + this.#fontGlyphIds = fontGlyphIds; - if (this.#frame?.active) this.#startLayer(); - return; + if (glyphIds === null || directoryChanged) { + this.#invalidGlyphIds.clear(); + for (const glyphId of fontGlyphIds) this.#invalidGlyphIds.add(glyphId); + if (this.#targetFrame) { + this.#targetFrame = { + ...this.#targetFrame, + previewExtents: { ...EMPTY_PREVIEW_EXTENTS }, + }; + } + } else { + const fontGlyphIdSet = new Set(fontGlyphIds); + for (const glyphId of glyphIds) { + if (fontGlyphIdSet.has(glyphId)) this.#invalidGlyphIds.add(glyphId); + } } - if (glyphIds.length === 0) return; - this.#layer?.invalidate(glyphIds); - const fontGlyphIdSet = new Set(fontGlyphIds); - for (const glyphId of glyphIds) { - if (fontGlyphIdSet.has(glyphId)) this.#invalidGlyphIds.add(glyphId); - } - this.#updateFullyResident(); - this.#refresh?.abort(new Error("resident glyphs changed")); - this.#firstFrameStarted = false; + this.#visibleBuild?.abort(new Error("resident visible frame changed")); + this.#completeBuild?.abort(new Error("resident complete atlas changed")); this.#needsRedraw = true; - this.#onReadyChange(false); + this.#updateFullyResident(); - if (this.#frame?.active) void this.#refreshVisible(); + if (this.#targetFrame?.active) void this.#refreshVisible(); } #startLayer(): void { @@ -196,95 +212,204 @@ export class GlyphCatalogController { } this.#layer = layer; - this.#invalidGlyphIds.clear(); - this.#updateFullyResident(); this.#refresh = null; this.#needsRedraw = true; - this.#firstFrameStarted = false; - this.redraw(); + this.#updateFullyResident(); + await this.#refreshVisible(); } catch (error) { if (this.#disposed || this.#refresh !== refresh) return; this.#refresh = null; if (refresh.signal.aborted) { - if (this.#frame?.active) this.#startLayer(); + if (this.#targetFrame?.active) this.#startLayer(); return; } console.error("resident glyph initialization failed", error); + this.#glyphCanvas.dataset.gridReadiness = "Unavailable" satisfies GridReadiness; this.#onUnavailable(); } } async #refreshVisible(): Promise { - if (this.#disposed || !this.#frame?.active || this.#refresh) return; + if (this.#disposed || !this.#targetFrame?.active || this.#refresh) return; const layer = this.#layer; if (!layer) { this.#startLayer(); return; } - const missingGlyphIds = new Set(this.#invalidGlyphIds); - for (const cell of this.#currentFrame().cells) { - if (!layer.hasGlyphs([cell.glyph.id])) missingGlyphIds.add(cell.glyph.id); + if (this.#visibleBuild) { + this.#visibleBuild.abort(new Error("visible Grid frame superseded")); + return; } - if (missingGlyphIds.size === 0) { + if (this.#completeBuild) { + this.#completeBuild.abort(new Error("visible Grid frame takes priority")); + return; + } + + const targetFrame = this.#targetFrame; + const visibleGlyphIds = this.#currentFrame(this.#layout(targetFrame), targetFrame).cells.map( + (cell) => cell.glyph.id, + ); + const glyphIds = visibleGlyphIds.filter( + (glyphId) => this.#invalidGlyphIds.has(glyphId) || !layer.hasGlyphs([glyphId]), + ); + if (glyphIds.length === 0) { + this.#activeFrame = targetFrame; this.#updateFullyResident(); + this.#needsRedraw = true; this.redraw(); + void this.#refreshComplete(); return; } - const glyphIds = [...missingGlyphIds]; - const refresh = new AbortController(); - this.#refresh = refresh; - this.#onReadyChange(false); + const visibleBuild = new AbortController(); + this.#visibleBuild = visibleBuild; + this.#updateFullyResident(); try { - await layer.loadPatch(glyphIds, refresh.signal); - if (this.#disposed || this.#refresh !== refresh) return; + const pageExtents = await layer.loadPatch(glyphIds, visibleBuild.signal); + if (this.#disposed || this.#visibleBuild !== visibleBuild || visibleBuild.signal.aborted) { + return; + } + + const latestTarget = this.#targetFrame; + if (!latestTarget) return; + const targetExtents = mergePreviewExtents(latestTarget.previewExtents, pageExtents); + const presentedExtents = mergePreviewExtents( + this.#activeFrame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, + targetExtents, + ); + this.#targetFrame = { ...latestTarget, previewExtents: targetExtents }; + this.#activeFrame = { ...latestTarget, previewExtents: presentedExtents }; for (const glyphId of glyphIds) this.#invalidGlyphIds.delete(glyphId); - this.#updateFullyResident(); - this.#refresh = null; + this.#onPreviewExtentsChange(presentedExtents); this.#needsRedraw = true; - this.#firstFrameStarted = false; + this.#updateFullyResident(); this.redraw(); } catch (error) { - if (this.#disposed || this.#refresh !== refresh) return; - this.#refresh = null; - if (refresh.signal.aborted) { - if (this.#frame?.active) void this.#refreshVisible(); - return; + if (!visibleBuild.signal.aborted) this.#handleReplacementFailure(error); + } finally { + if (this.#visibleBuild === visibleBuild) this.#visibleBuild = null; + } + + if (visibleBuild.signal.aborted) { + void this.#refreshVisible(); + return; + } + void this.#refreshComplete(); + } + + async #refreshComplete(): Promise { + if ( + this.#disposed || + !this.#targetFrame?.active || + !this.#layer || + this.#refresh || + this.#visibleBuild || + this.#completeBuild + ) { + return; + } + + const targetFrame = this.#targetFrame; + const visibleGlyphIds = this.#currentFrame(this.#layout(targetFrame), targetFrame).cells.map( + (cell) => cell.glyph.id, + ); + if ( + visibleGlyphIds.some( + (glyphId) => this.#invalidGlyphIds.has(glyphId) || !this.#layer?.hasGlyphs([glyphId]), + ) + ) { + void this.#refreshVisible(); + return; + } + + const completeBuild = new AbortController(); + this.#completeBuild = completeBuild; + + try { + for (let start = 0; start < this.#fontGlyphIds.length; start += ATLAS_PAGE_ROOT_COUNT) { + if (completeBuild.signal.aborted) break; + + const pageGlyphIds = this.#fontGlyphIds.slice(start, start + ATLAS_PAGE_ROOT_COUNT); + const needsReplacement = pageGlyphIds.some( + (glyphId) => this.#invalidGlyphIds.has(glyphId) || !this.#layer?.hasGlyphs([glyphId]), + ); + if (!needsReplacement) continue; + + const pageExtents = await this.#layer.loadPatch(pageGlyphIds, completeBuild.signal); + if (completeBuild.signal.aborted) break; + + const latestTarget = this.#targetFrame; + if (!latestTarget) break; + const targetExtents = mergePreviewExtents(latestTarget.previewExtents, pageExtents); + const presentedExtents = mergePreviewExtents( + this.#activeFrame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, + targetExtents, + ); + this.#targetFrame = { ...latestTarget, previewExtents: targetExtents }; + if (this.#activeFrame) { + this.#activeFrame = { ...this.#activeFrame, previewExtents: presentedExtents }; + } + for (const glyphId of pageGlyphIds) this.#invalidGlyphIds.delete(glyphId); + this.#onPreviewExtentsChange(presentedExtents); + this.#needsRedraw = true; + this.#updateFullyResident(); + this.redraw(); + + await new Promise((resolve) => setTimeout(resolve, 0)); } - this.#failFrame(layer, error); + } catch (error) { + if (!completeBuild.signal.aborted) this.#handleReplacementFailure(error); + } finally { + if (this.#completeBuild === completeBuild) this.#completeBuild = null; } + + if (completeBuild.signal.aborted) void this.#refreshVisible(); } #handleDeviceLoss(reason: string): void { if (this.#disposed) return; console.error("resident glyph device lost", reason); this.#refresh?.abort(new Error(reason)); + this.#visibleBuild?.abort(new Error(reason)); + this.#completeBuild?.abort(new Error(reason)); this.#refresh = null; + this.#visibleBuild = null; + this.#completeBuild = null; this.#layer = null; + this.#activeFrame = null; + this.#invalidGlyphIds.clear(); + for (const glyphId of this.#fontGlyphIds) this.#invalidGlyphIds.add(glyphId); this.#glyphCanvas.dataset.fullyResident = "false"; + this.#glyphCanvas.dataset.gridReadiness = "Unavailable" satisfies GridReadiness; this.#firstFrameStarted = false; this.#needsRedraw = true; this.#onReadyChange(false); this.#onUnavailable(); } - #layout(): GlyphCatalogLayout { + #layout(frame = this.#activeFrame ?? this.#targetFrame): GlyphCatalogLayout { + const metrics = frame?.metrics; return new GlyphCatalogLayout( this.#container.clientWidth, this.#container.clientHeight, - this.#frame?.glyphs.length ?? 0, + frame?.glyphs.length ?? 0, + metrics ?? fallbackMetrics(), + frame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, ); } - #currentFrame(layout = this.#layout()): GlyphCatalogFrame { - return layout.frame(this.#frame?.glyphs ?? [], this.#container.scrollTop); + #currentFrame( + layout = this.#layout(), + input = this.#activeFrame ?? this.#targetFrame, + ): GlyphCatalogFrame { + return layout.frame(input?.glyphs ?? [], this.#container.scrollTop); } #draw(): void { if (this.#disposed) return; - const input = this.#frame; + const input = this.#activeFrame; if (!input?.active) return; const ratio = window.devicePixelRatio; @@ -295,8 +420,8 @@ export class GlyphCatalogController { this.#needsRedraw = true; } - const layout = this.#layout(); - const frame = this.#currentFrame(layout); + const layout = this.#layout(input); + const frame = this.#currentFrame(layout, input); const hoveredCell = this.#pointer ? layout.hit(frame, this.#pointer) : null; this.#updateHoveredCatalogIndex(hoveredCell?.catalogIndex ?? null); this.#overlay.draw(this.#container, frame, hoveredCell?.catalogIndex ?? null); @@ -308,12 +433,7 @@ export class GlyphCatalogController { const layer = this.#layer; if (!layer) return; const visibleGlyphIds = frame.cells.map((cell) => cell.glyph.id); - if ( - visibleGlyphIds.some((glyphId) => this.#invalidGlyphIds.has(glyphId)) || - !layer.hasGlyphs(visibleGlyphIds) - ) { - this.#firstFrameStarted = false; - this.#onReadyChange(false); + if (!layer.hasGlyphs(visibleGlyphIds)) { void this.#refreshVisible(); return; } @@ -335,7 +455,10 @@ export class GlyphCatalogController { frame.scrollTop + frame.layout.viewportHeight > 0; if (instances.length > 0 || input.glyphs.length === 0 || !catalogIntersectsViewport) { - const [viewHeight, fontTop] = GlyphPreviewLayout.fontViewport(input.metrics); + const [viewHeight, fontTop] = GlyphPreviewLayout.fontViewport( + input.metrics, + input.previewExtents, + ); layer.draw({ location: input.location, axes: input.axes, @@ -344,7 +467,7 @@ export class GlyphCatalogController { viewHeight, fontTop, previewHeight: frame.layout.previewHeight * ratio, - sideMargin: GlyphPreviewLayout.sideMargin(input.metrics), + sideMargin: GlyphPreviewLayout.sideMargin(input.metrics, input.previewExtents), color: parseCssColor(getComputedStyle(this.#container).color), }, viewportWidth: this.#glyphCanvas.width, @@ -360,49 +483,70 @@ export class GlyphCatalogController { this.#onReadyChange(true); } } catch (error) { - this.#failFrame(layer, error); + this.#handleReplacementFailure(error); } } async #completeFirstFrame(layer: ResidentGlyphLayer): Promise { try { await layer.complete(); - if (this.#disposed || this.#layer !== layer) return; + if (this.#disposed || this.#layer !== layer || !this.#activeFrame) return; const visibleGlyphIds = this.#currentFrame().cells.map((cell) => cell.glyph.id); - if ( - visibleGlyphIds.some((glyphId) => this.#invalidGlyphIds.has(glyphId)) || - !layer.hasGlyphs(visibleGlyphIds) - ) { - return; - } + if (!layer.hasGlyphs(visibleGlyphIds)) return; + this.#onReadyChange(true); } catch (error) { if (this.#disposed || this.#layer !== layer) return; - this.#failFrame(layer, error); + this.#handleReplacementFailure(error); } } - #failFrame(layer: ResidentGlyphLayer, error: unknown): void { - console.error("resident glyph frame failed", error); - layer.destroy(); - if (this.#layer === layer) this.#layer = null; - this.#glyphCanvas.dataset.fullyResident = "false"; - this.#refresh?.abort(new Error("resident glyph frame failed")); - this.#refresh = null; - this.#firstFrameStarted = false; + #handleReplacementFailure(error: unknown): void { + console.error("resident glyph replacement failed", error); this.#needsRedraw = true; + if (this.#activeFrame) { + this.#updateFullyResident(); + this.redraw(); + return; + } + + this.#glyphCanvas.dataset.gridReadiness = "Unavailable" satisfies GridReadiness; this.#onReadyChange(false); this.#onUnavailable(); } #updateFullyResident(): void { const layer = this.#layer; - const glyphIds = this.#frame?.glyphs.map((glyph) => glyph.id) ?? []; - const fullyResident = + const complete = Boolean(layer) && - glyphIds.every((glyphId) => !this.#invalidGlyphIds.has(glyphId)) && - Boolean(layer?.hasGlyphs(glyphIds)); - this.#glyphCanvas.dataset.fullyResident = String(fullyResident); + this.#fontGlyphIds.every( + (glyphId) => !this.#invalidGlyphIds.has(glyphId) && Boolean(layer?.hasGlyphs([glyphId])), + ); + this.#glyphCanvas.dataset.fullyResident = String(complete); + this.#glyphCanvas.dataset.residentGlyphCount = String( + this.#fontGlyphIds.filter( + (glyphId) => !this.#invalidGlyphIds.has(glyphId) && Boolean(layer?.hasGlyphs([glyphId])), + ).length, + ); + this.#glyphCanvas.dataset.targetGlyphCount = String(this.#fontGlyphIds.length); + const activeLayout = this.#activeFrame ? this.#layout(this.#activeFrame) : null; + this.#glyphCanvas.dataset.previewHeight = String(activeLayout?.previewHeight ?? 0); + this.#glyphCanvas.dataset.previewHorizontal = String( + this.#activeFrame?.previewExtents.horizontal ?? 0, + ); + + let readiness: GridReadiness = "Initial"; + if (this.#activeFrame) { + const target = this.#targetFrame; + const visibleGlyphIds = target + ? this.#currentFrame(this.#layout(target), target).cells.map((cell) => cell.glyph.id) + : []; + const visible = visibleGlyphIds.every( + (glyphId) => !this.#invalidGlyphIds.has(glyphId) && Boolean(layer?.hasGlyphs([glyphId])), + ); + readiness = complete ? "Complete" : visible ? "Visible" : "Stale"; + } + this.#glyphCanvas.dataset.gridReadiness = readiness; } #updateHoveredCatalogIndex(nextIndex: number | null): void { @@ -413,6 +557,7 @@ export class GlyphCatalogController { #handleScroll = (): void => { this.#needsRedraw = true; + void this.#refreshVisible(); this.redraw(); }; @@ -431,17 +576,18 @@ export class GlyphCatalogController { }; #handleClick = (event: MouseEvent): void => { - const layout = this.#layout(); - const frame = this.#currentFrame(layout); + const input = this.#activeFrame; + if (!input) return; + + const layout = this.#layout(input); + const frame = this.#currentFrame(layout, input); const point = CanvasSurface.localPoint(this.#container, { x: event.clientX, y: event.clientY, }); const nameCell = layout.hit(frame, point, "name"); if (nameCell) { - if (this.#frame) { - this.#frame = { ...this.#frame, editingGlyphId: nameCell.glyph.id }; - } + this.#activeFrame = { ...input, editingGlyphId: nameCell.glyph.id }; this.#onEditGlyph(nameCell.glyph); return; } @@ -470,3 +616,34 @@ export class GlyphCatalogController { } } } + +function mergePreviewExtents( + current: SlugPreviewExtents, + next: SlugPreviewExtents, +): SlugPreviewExtents { + return { + horizontal: Math.max(current.horizontal, next.horizontal), + minimumY: Math.min(current.minimumY, next.minimumY), + maximumY: Math.max(current.maximumY, next.maximumY), + }; +} + +function sameGlyphIds(left: readonly GlyphId[], right: readonly GlyphId[]): boolean { + return left.length === right.length && left.every((glyphId, index) => glyphId === right[index]); +} + +function fallbackMetrics() { + return { + unitsPerEm: 1000, + metricValues: [], + ascender: 800, + descender: -200, + xHeight: 500, + capHeight: 700, + baseline: 0, + italicAngle: 0, + lineGap: 0, + underlinePosition: -100, + underlineThickness: 50, + }; +} diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 3d2ebc0d..9609968e 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -1,3 +1,4 @@ +import type { SlugPreviewExtents } from "@shift/types"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router"; import { GlyphCatalogCanvas } from "./GlyphCatalogCanvas"; @@ -23,11 +24,23 @@ export const GlyphGrid = memo(function GlyphGrid() { readonly [width: number, height: number] >([0, 0]); const [catalogReady, setCatalogReady] = useState(false); + const [previewExtents, setPreviewExtents] = useState({ + horizontal: 0, + minimumY: 0, + maximumY: 0, + }); + const metrics = useMemo(() => font.metricsAtLocation(location), [font, location]); const layout = useMemo( - () => new GlyphCatalogLayout(viewportWidth, viewportHeight, filteredGlyphs.length), - [filteredGlyphs.length, viewportHeight, viewportWidth], + () => + new GlyphCatalogLayout( + viewportWidth, + viewportHeight, + filteredGlyphs.length, + metrics, + previewExtents, + ), + [filteredGlyphs.length, metrics, previewExtents, viewportHeight, viewportWidth], ); - const metrics = useMemo(() => font.metricsAtLocation(location), [font, location]); const axes = font.getAxes(); const sourceId = font.sourceAt(location)?.id ?? null; const initialMeasurementLoggedRef = useRef(false); @@ -108,6 +121,7 @@ export const GlyphGrid = memo(function GlyphGrid() { openGlyph={handleCellClick} onFirstFrame={handleCatalogReady} onUnavailable={handleCatalogUnavailable} + onPreviewExtentsChange={setPreviewExtents} /> ); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts index b64dd212..23261116 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts @@ -31,4 +31,11 @@ describe("Glyph preview layout", () => { expect(layout.viewBox).toBe("0 -1000 1 1250"); expect(layout.width).toBe(75); }); + + it("extends the shared viewport without changing its font-space scale", () => { + const extents = { horizontal: 200, minimumY: -500, maximumY: 1500 }; + + expect(GlyphPreviewLayout.fontViewport(METRICS, extents)).toEqual([2000, 1500]); + expect(GlyphPreviewLayout.sideMargin(METRICS, extents)).toBe(200); + }); }); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts index 7b9b2417..aeab5ee0 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts @@ -1,4 +1,4 @@ -import type { SourceMetrics } from "@shift/types"; +import type { SlugPreviewExtents, SourceMetrics } from "@shift/types"; const MARGIN_TOP_RATIO = 0.2; const MARGIN_BOTTOM_RATIO = 0.05; @@ -22,17 +22,22 @@ export class GlyphPreviewLayout { } /** Shared horizontal margin used by fallback and resident previews. */ - static sideMargin(metrics: SourceMetrics): number { - return metrics.unitsPerEm * MARGIN_SIDE_RATIO; + static sideMargin(metrics: SourceMetrics, previewExtents?: SlugPreviewExtents): number { + return Math.max(metrics.unitsPerEm * MARGIN_SIDE_RATIO, previewExtents?.horizontal ?? 0); } /** Shared font-space viewport used by fallback and resident previews. */ - static fontViewport(metrics: SourceMetrics): readonly [viewHeight: number, fontTop: number] { + static fontViewport( + metrics: SourceMetrics, + previewExtents?: SlugPreviewExtents, + ): readonly [viewHeight: number, fontTop: number] { const marginTop = metrics.unitsPerEm * MARGIN_TOP_RATIO; const marginBottom = metrics.unitsPerEm * MARGIN_BOTTOM_RATIO; - return [ - metrics.ascender - metrics.descender + marginTop + marginBottom, - metrics.ascender + marginTop, - ]; + const metricsTop = metrics.ascender + marginTop; + const metricsBottom = metrics.descender - marginBottom; + const fontTop = Math.max(metricsTop, previewExtents?.maximumY ?? metricsTop); + const fontBottom = Math.min(metricsBottom, previewExtents?.minimumY ?? metricsBottom); + + return [fontTop - fontBottom, fontTop]; } } diff --git a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts index d2fdddba..c51499c4 100644 --- a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts +++ b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts @@ -1,8 +1,23 @@ import { describe, expect, it } from "vitest"; -import type { GlyphId, GlyphName } from "@shift/types"; +import type { GlyphId, GlyphName, SlugPreviewExtents, SourceMetrics } from "@shift/types"; import type { GlyphCatalogItem } from "@/types/glyphCatalog"; import { GlyphCatalogLayout } from "./glyphCatalogLayout"; +const METRICS: SourceMetrics = { + unitsPerEm: 1000, + metricValues: [], + ascender: 800, + descender: -200, + xHeight: 500, + capHeight: 700, + baseline: 0, + italicAngle: 0, + lineGap: 0, + underlinePosition: -100, + underlineThickness: 50, +}; +const NO_OVERFLOW: SlugPreviewExtents = { horizontal: 0, minimumY: 0, maximumY: 0 }; + function catalog(count: number): GlyphCatalogItem[] { return Array.from({ length: count }, (_, index) => ({ id: `glyph-${index}` as GlyphId, @@ -12,36 +27,45 @@ function catalog(count: number): GlyphCatalogItem[] { })); } +function layout( + width: number, + height: number, + glyphCount: number, + previewExtents = NO_OVERFLOW, +): GlyphCatalogLayout { + return new GlyphCatalogLayout(width, height, glyphCount, METRICS, previewExtents); +} + describe("canvas-owned Glyph catalog layout", () => { it("distributes nominal cells into columns and derives the complete scroll height", () => { - const layout = new GlyphCatalogLayout(500, 240, 9); + const result = layout(500, 240, 9); - expect(layout.columns).toBe(4); - expect(layout.cellWidth).toBe(101); - expect(layout.rowCount).toBe(3); - expect(layout.totalHeight).toBe(409); + expect(result.columns).toBe(4); + expect(result.cellWidth).toBe(101); + expect(result.rowCount).toBe(3); + expect(result.totalHeight).toBe(409); }); it("changes columns, cell width, and total height when the viewport resizes", () => { - const layout = new GlyphCatalogLayout(350, 240, 9); + const result = layout(350, 240, 9); - expect(layout.columns).toBe(2); - expect(layout.cellWidth).toBe(135); - expect(layout.rowCount).toBe(5); - expect(layout.totalHeight).toBe(655); + expect(result.columns).toBe(2); + expect(result.cellWidth).toBe(135); + expect(result.rowCount).toBe(5); + expect(result.totalHeight).toBe(655); }); it("derives top, middle, and end cells from catalog order and scrollTop", () => { const glyphs = catalog(20); - const layout = new GlyphCatalogLayout(500, 200, glyphs.length); + const result = layout(500, 200, glyphs.length); - expect(layout.frame(glyphs, 0).cells.map((cell) => cell.catalogIndex)).toEqual([ + expect(result.frame(glyphs, 0).cells.map((cell) => cell.catalogIndex)).toEqual([ 0, 1, 2, 3, 4, 5, 6, 7, ]); - expect(layout.frame(glyphs, 143).cells.map((cell) => cell.catalogIndex)).toEqual([ + expect(result.frame(glyphs, 143).cells.map((cell) => cell.catalogIndex)).toEqual([ 4, 5, 6, 7, 8, 9, 10, 11, ]); - expect(layout.frame(glyphs, 999).cells.map((cell) => cell.catalogIndex)).toEqual([ + expect(result.frame(glyphs, 999).cells.map((cell) => cell.catalogIndex)).toEqual([ 12, 13, 14, 15, 16, 17, 18, 19, ]); }); @@ -49,8 +73,8 @@ describe("canvas-owned Glyph catalog layout", () => { it("keeps filtered IDs and their rectangles aligned after resizing", () => { const allGlyphs = catalog(8); const filtered = [allGlyphs[2]!, allGlyphs[5]!, allGlyphs[7]!]; - const narrow = new GlyphCatalogLayout(280, 180, filtered.length).frame(filtered, 0); - const wide = new GlyphCatalogLayout(500, 180, filtered.length).frame(filtered, 0); + const narrow = layout(280, 180, filtered.length).frame(filtered, 0); + const wide = layout(500, 180, filtered.length).frame(filtered, 0); expect(narrow.cells.map((cell) => cell.glyph.id)).toEqual(["glyph-2", "glyph-5", "glyph-7"]); expect(narrow.cells[2]?.previewRect).toMatchObject({ x: 36, y: 143, width: 100, height: 75 }); @@ -59,24 +83,37 @@ describe("canvas-owned Glyph catalog layout", () => { it("hits preview tiles but excludes labels, gaps, and viewport padding", () => { const glyphs = catalog(3); - const layout = new GlyphCatalogLayout(280, 200, glyphs.length); - const frame = layout.frame(glyphs, 0); - - expect(layout.hit(frame, { x: 37, y: 21 })?.catalogIndex).toBe(0); - expect(layout.hit(frame, { x: 37, y: 104 })).toBeNull(); - expect(layout.hit(frame, { x: 37, y: 104 }, "name")?.catalogIndex).toBe(0); - expect(layout.hit(frame, { x: 37, y: 99 })).toBeNull(); - expect(layout.hit(frame, { x: 140, y: 21 })).toBeNull(); - expect(layout.hit(frame, { x: 5, y: 5 })).toBeNull(); + const result = layout(280, 200, glyphs.length); + const frame = result.frame(glyphs, 0); + + expect(result.hit(frame, { x: 37, y: 21 })?.catalogIndex).toBe(0); + expect(result.hit(frame, { x: 37, y: 104 })).toBeNull(); + expect(result.hit(frame, { x: 37, y: 104 }, "name")?.catalogIndex).toBe(0); + expect(result.hit(frame, { x: 37, y: 99 })).toBeNull(); + expect(result.hit(frame, { x: 140, y: 21 })).toBeNull(); + expect(result.hit(frame, { x: 5, y: 5 })).toBeNull(); }); it("never loses all cells while a non-empty catalog viewport scrolls through content", () => { const glyphs = catalog(17); - const layout = new GlyphCatalogLayout(280, 60, glyphs.length); - const maximumScrollTop = layout.totalHeight - layout.viewportHeight; + const result = layout(280, 60, glyphs.length); + const maximumScrollTop = result.totalHeight - result.viewportHeight; for (let scrollTop = 0; scrollTop <= maximumScrollTop; scrollTop += 1) { - expect(layout.frame(glyphs, scrollTop).cells.length).toBeGreaterThan(0); + expect(result.frame(glyphs, scrollTop).cells.length).toBeGreaterThan(0); } }); + + it("expands every cell for font-wide bounds without changing pixels per em", () => { + const result = layout(500, 240, 9, { + horizontal: 200, + minimumY: -500, + maximumY: 1500, + }); + + expect(result.previewHeight).toBe(120); + expect(result.columns).toBe(3); + expect(result.rowPitch).toBe(168); + expect(result.totalHeight).toBe(544); + }); }); diff --git a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts index f28ff8ca..70b1401b 100644 --- a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts +++ b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts @@ -1,4 +1,6 @@ import { Rect, type Point2D } from "@shift/geo"; +import type { SlugPreviewExtents, SourceMetrics } from "@shift/types"; +import { GlyphPreviewLayout } from "./GlyphPreviewLayout"; import type { GlyphCatalogCell, GlyphCatalogCellArea, @@ -11,12 +13,11 @@ const VIEWPORT_PADDING = 20; const GRID_INSET = 16; const COLUMN_GAP = 8; const NOMINAL_CELL_WIDTH = 100; -const ROW_PITCH = 123; const PREVIEW_HEIGHT = 75; const PREVIEW_CONTENT_INSET = 16; const NAME_GAP = 8; const NAME_HEIGHT = 28; -const CELL_HEIGHT = PREVIEW_HEIGHT + NAME_GAP + NAME_HEIGHT; +const ROW_GAP = 12; /** Immutable screen-space layout for one glyph catalog viewport. */ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { @@ -32,27 +33,42 @@ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { readonly gridLeft = VIEWPORT_PADDING + GRID_INSET; readonly gridWidth: number; readonly columnGap = COLUMN_GAP; - readonly rowPitch = ROW_PITCH; - readonly previewHeight = PREVIEW_HEIGHT; + readonly rowPitch: number; + readonly previewHeight: number; readonly previewContentInset = PREVIEW_CONTENT_INSET; readonly nameGap = NAME_GAP; readonly nameHeight = NAME_HEIGHT; - constructor(viewportWidth: number, viewportHeight: number, glyphCount: number) { + constructor( + viewportWidth: number, + viewportHeight: number, + glyphCount: number, + metrics: SourceMetrics, + previewExtents: SlugPreviewExtents, + ) { this.viewportWidth = finiteNonNegative(viewportWidth); this.viewportHeight = finiteNonNegative(viewportHeight); this.glyphCount = Math.max(0, Math.floor(finiteNonNegative(glyphCount))); + + const [baseViewHeight] = GlyphPreviewLayout.fontViewport(metrics); + const [expandedViewHeight] = GlyphPreviewLayout.fontViewport(metrics, previewExtents); + const pixelsPerEm = PREVIEW_HEIGHT / Math.max(1, baseViewHeight); + this.previewHeight = expandedViewHeight * pixelsPerEm; + this.rowPitch = this.previewHeight + NAME_GAP + NAME_HEIGHT + ROW_GAP; + + const horizontalOverflow = 2 * previewExtents.horizontal * pixelsPerEm; + const nominalCellWidth = NOMINAL_CELL_WIDTH + horizontalOverflow; this.gridWidth = Math.max(0, this.viewportWidth - 2 * this.gridLeft); this.columns = this.gridWidth > 0 - ? Math.max(1, Math.floor((this.gridWidth + COLUMN_GAP) / (NOMINAL_CELL_WIDTH + COLUMN_GAP))) + ? Math.max(1, Math.floor((this.gridWidth + COLUMN_GAP) / (nominalCellWidth + COLUMN_GAP))) : 0; this.cellWidth = this.columns > 0 ? (this.gridWidth - Math.max(0, this.columns - 1) * COLUMN_GAP) / this.columns : 0; this.rowCount = this.columns > 0 ? Math.ceil(this.glyphCount / this.columns) : 0; - this.totalHeight = this.rowCount > 0 ? 2 * VIEWPORT_PADDING + this.rowCount * ROW_PITCH : 0; + this.totalHeight = this.rowCount > 0 ? 2 * VIEWPORT_PADDING + this.rowCount * this.rowPitch : 0; } /** Derives only the cells intersecting the current native scroll viewport. */ @@ -106,7 +122,12 @@ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { cells.push({ catalogIndex, glyph, - cellRect: Rect.fromXYWH(x, y, this.cellWidth, CELL_HEIGHT), + cellRect: Rect.fromXYWH( + x, + y, + this.cellWidth, + this.previewHeight + this.nameGap + this.nameHeight, + ), previewRect, previewContentRect, nameRect, diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts index 0ae9d3b2..1ab1c6ea 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts @@ -1,4 +1,4 @@ -import type { GlyphId } from "@shift/types"; +import type { GlyphId, SlugPreviewExtents } from "@shift/types"; import type { GlyphPreviewFrame } from "@/types/glyphPreview"; import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; import { SlugAtlas } from "@/lib/slug/SlugAtlas"; @@ -40,20 +40,10 @@ export class ResidentGlyphLayer { ): Promise { if (!navigator.gpu) throw new Error("WebGPU is unavailable"); - let preparedGeneration: number | null = null; let device: GPUDevice | null = null; let context: GPUCanvasContext | null = null; - let atlas: SlugAtlas | null = null; let renderer: SlugRenderer | null = null; - async function discardPrepared(): Promise { - if (preparedGeneration === null) return; - - const generation = preparedGeneration; - preparedGeneration = null; - await edits.discardSlugAtlas(generation); - } - try { const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); throwIfAborted(signal); @@ -65,31 +55,15 @@ export class ResidentGlyphLayer { } const alignment = Math.max(COPY_ALIGNMENT, adapter.limits.minStorageBufferOffsetAlignment); - const descriptor = await edits.prepareSlugAtlas(alignment); - preparedGeneration = descriptor.generation; - throwIfAborted(signal); - - const adapterMaximumBindingSize = Math.min( + const maximumBindingSize = Math.min( adapter.limits.maxBufferSize, adapter.limits.maxStorageBufferBindingSize, + PREFERRED_ATLAS_BYTES, ); - const [, firstLength, secondLength] = SlugAtlas.bindingLengths( - descriptor.layout.totalLength, - adapterMaximumBindingSize, - ); - const largestResidentBuffer = Math.max(firstLength, secondLength); - if (descriptor.layout.totalLength > PREFERRED_ATLAS_BYTES) { - console.warn("resident glyph atlas exceeds preferred size", { - bytes: descriptor.layout.totalLength, - preferredBytes: PREFERRED_ATLAS_BYTES, - adapterMaximumBytes: adapterMaximumBindingSize * 2, - }); - } - device = await adapter.requestDevice({ requiredLimits: { - maxBufferSize: largestResidentBuffer, - maxStorageBufferBindingSize: largestResidentBuffer, + maxBufferSize: maximumBindingSize, + maxStorageBufferBindingSize: maximumBindingSize, maxStorageBuffersPerShaderStage: REQUIRED_STORAGE_BUFFERS, }, }); @@ -100,33 +74,6 @@ export class ResidentGlyphLayer { const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: "premultiplied" }); renderer = new SlugRenderer(device, context, format, onDeviceLost); - const maximumBindingSize = Math.min( - device.limits.maxBufferSize, - device.limits.maxStorageBufferBindingSize, - ); - atlas = SlugAtlas.create(descriptor, device, maximumBindingSize); - const activeAtlas = atlas; - const uploadDevice = device; - const totalLength = await edits.streamSlugAtlas( - descriptor.generation, - UPLOAD_CHUNK_BYTES, - (offset, bytes) => { - throwIfAborted(signal); - activeAtlas.write(uploadDevice.queue, offset, bytes); - }, - ); - preparedGeneration = null; - throwIfAborted(signal); - if (totalLength !== descriptor.layout.totalLength) { - throw new Error( - `resident glyph stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, - ); - } - - const loadedAtlas = atlas; - atlas = null; - renderer.loadPage(loadedAtlas); - throwIfAborted(signal); const layer = new ResidentGlyphLayer(renderer, device, edits, alignment, maximumBindingSize); renderer = null; @@ -139,20 +86,16 @@ export class ResidentGlyphLayer { context = null; device = null; } - atlas?.destroy(); context?.unconfigure(); device?.destroy(); - try { - await discardPrepared(); - } catch (discardError) { - console.error("failed to release rejected resident glyph atlas", discardError); - } throw error; } } - async loadPatch(glyphIds: readonly GlyphId[], signal: AbortSignal): Promise { - if (glyphIds.length === 0) return; + async loadPatch(glyphIds: readonly GlyphId[], signal: AbortSignal): Promise { + if (glyphIds.length === 0) { + return { horizontal: 0, minimumY: 0, maximumY: 0 }; + } let preparedGeneration: number | null = null; let atlas: SlugAtlas | null = null; @@ -190,6 +133,7 @@ export class ResidentGlyphLayer { const loadedAtlas = atlas; atlas = null; this.#renderer.loadPage(loadedAtlas); + return descriptor.previewExtents; } catch (error) { atlas?.destroy(); if (preparedGeneration !== null) { diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index f1dbb4ea..a1a6ed79 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -8,9 +8,13 @@ Renderer vector-path values and the accelerated marker-layer backend for editor - **Architecture Invariant:** `Renderer` owns the `MarkerLayer` lifecycle. `CanvasContextProvider` only reports DOM canvas mount, resize, and unmount events. -- **Architecture Invariant:** `ResidentGlyphLayer` is the generic catalog-preview boundary. It owns one WebGPU adapter/device/context, one complete base atlas, and independently replaceable edit patches; Slug names and packed-layout knowledge remain behind that backend rather than entering React or catalog frame types. +- **Architecture Invariant:** `ResidentGlyphLayer` is the generic catalog-preview boundary. It owns one WebGPU adapter/device/context and independently replaceable authored root pages; Slug names and packed-layout knowledge remain behind that backend rather than entering React catalog components. -- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw because Chromium may discard the WebGPU canvas presentation. Readiness means the complete atlas, including any required local edit patches, is current and its submitted frame completed. +- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw because Chromium may discard the WebGPU canvas presentation. Initial readiness means the current visible page is complete and its submitted frame completed; complete residency is tracked separately while fixed root pages fill cooperatively. + +- **Architecture Invariant:** Atlas invalidation never removes a presented root before its replacement page is uploaded. Axis, source, mapping, directory, and structural changes retain the prior frame, prioritize every visible root at the new authored revision, swap that frame atomically, and then replace offscreen fixed pages. One bounded native page may occupy the utility lane; no monolithic complete-font request blocks later visible work. + +- **Architecture Invariant:** Preview scale remains metrics-derived. `SlugPreviewExtents` expands every cell from the font-wide all-source overflow without changing pixels per em; extents grow as pages arrive and stale extents remain safe during replacement. - **Architecture Invariant:** **CRITICAL**: The instance buffer layout (attribute offsets in the draw command) must exactly match the packing order in `MarkerHandleRenderer.#writeInstance`. If either side changes stride/offset, handles render garbage with no error. @@ -51,7 +55,7 @@ editor/rendering/markers/ - `MarkerLayer` -- WebGL context wrapper. Manages REGL instance, instance buffer, and draw command. Provides `resizeCanvas`, `draw`, `clear`, `destroy`, and `isAvailable`. -- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It prepares and streams one complete native Slug atlas, retains one device/context, and overlays small replacement patches for locally invalidated roots. +- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It retains one device/context and prepares, streams, and atomically installs independently replaceable native Slug root pages. - `MarkerInstance` -- logical representation of one marker shape. The current marker path packs directly into a `Float32Array` for zero steady-state allocation. @@ -73,7 +77,9 @@ editor/rendering/markers/ ### Resident catalog lifecycle -`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency acquires every authored layer, streams one complete atlas, and marks the glyph canvas `data-fully-resident="true"`. Scrolling in either direction therefore only submits frames and never prepares or uploads geometry. Local edits invalidate touched roots and component dependents, then stream one replacement patch while untouched roots remain in the complete base atlas. Axis/source changes discard the complete atlas and rebuild it because every root is invalid. Invalid or missing roots are never submitted, so stale previews are not displayed. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. +`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency prepares only roots intersecting the current viewport, submits that complete page, and then `#refreshComplete` fills deterministic directory pages while yielding between native calls. Local edits and global axis/source changes leave the active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes the newly visible roots. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. + +Every page reports all-source `SlugPreviewExtents`. The controller monotonically merges those bounds during the active generation, and `GlyphCatalogLayout` expands shared cell width, preview height, and row pitch using the existing metrics-derived pixels-per-em ratio. Oversized glyphs therefore retain the same scale rather than being individually fitted or clipped to the metrics box. ### Per-frame draw pipeline diff --git a/apps/desktop/src/renderer/src/types/glyphCatalog.ts b/apps/desktop/src/renderer/src/types/glyphCatalog.ts index 89dbb027..7d1b3010 100644 --- a/apps/desktop/src/renderer/src/types/glyphCatalog.ts +++ b/apps/desktop/src/renderer/src/types/glyphCatalog.ts @@ -1,6 +1,13 @@ import type { GlyphCategory, GlyphCategorySummary } from "@shift/glyph-info"; import type { Rect2D } from "@shift/geo"; -import type { Axis, GlyphId, GlyphName, SourceId, SourceMetrics } from "@shift/types"; +import type { + Axis, + GlyphId, + GlyphName, + SlugPreviewExtents, + SourceId, + SourceMetrics, +} from "@shift/types"; import type { RefObject } from "react"; import type { ThemeName } from "./uiState"; import type { AxisLocation } from "./variation"; @@ -63,7 +70,9 @@ export interface GlyphCatalogFrame { readonly cells: readonly GlyphCatalogCell[]; } -/** Mutable catalog inputs replaced atomically before the next scheduled frame. */ +export type GridReadiness = "Initial" | "Stale" | "Visible" | "Complete" | "Unavailable"; + +/** Mutable catalog inputs requested by React for the latest authored revision. */ export interface GlyphCatalogControllerFrame { readonly glyphs: readonly GlyphCatalogItem[]; readonly location: AxisLocation; @@ -75,6 +84,11 @@ export interface GlyphCatalogControllerFrame { readonly editingGlyphId: GlyphId | null; } +/** Complete immutable Grid input presented with one shared preview extent. */ +export interface GridFrame extends GlyphCatalogControllerFrame { + readonly previewExtents: SlugPreviewExtents; +} + export interface GlyphNameInputProps { readonly glyph: GlyphCatalogItem; readonly onFinished: () => void; @@ -91,4 +105,5 @@ export interface GlyphCatalogCanvasProps { readonly openGlyph: (glyph: GlyphCatalogItem) => Promise; readonly onFirstFrame: () => void; readonly onUnavailable: () => void; + readonly onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void; } diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index a2972caf..c56e1e25 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -128,7 +128,6 @@ export type ShellCallMap = { request: { path: string; packageIdentity?: WorkspacePackageIdentity }; response: WorkspaceDocumentState; }; - "workspace.prepareAuthoredGlyphCompilation": { request: void; response: void }; "workspace.close": { request: { discard: boolean }; response: null }; "workspace.connect": { request: void; response: void }; "document.state": { request: void; response: WorkspaceDocumentState | null }; diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index 3e955256..11e42543 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -241,7 +241,6 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { const snapshot = await createWorkspace(sync); const glyph = createGlyphALayer(snapshot.sources[0]!.id); await applyWorkspace(sync, { intents: glyph.intents }); - await shell.call("workspace.prepareAuthoredGlyphCompilation", undefined); const atlas = await sync.call("workspace.slugAtlasPrepare", { alignment: 256 }); const bytes = await streamSlugAtlas(sync, atlas.generation, 64); diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 2c691775..4e633f80 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -69,8 +69,6 @@ export class WorkspaceHost { "workspace.inspectPackage": ({ path }) => this.#serialize(() => this.#inspectPackage(path)), "workspace.open": ({ path, packageIdentity }) => this.#serialize(() => this.#open(path, packageIdentity)), - "workspace.prepareAuthoredGlyphCompilation": () => - this.#serialize(() => this.#bridge.prepareAuthoredGlyphCompilation()), "workspace.close": ({ discard }) => this.#serialize(() => this.#close(discard)), "workspace.connect": (_payload, context) => { this.#connectSyncLane(context.ports); diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index 236d48e1..a3310eea 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -14,13 +14,13 @@ NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bri **Architecture Invariant:** Export explicitly acquires all persisted layers before taking a clone/COW `FontSaveSnapshot`. **WHY:** Async export gets a complete stable view while ordinary workspace open remains directory-first and lazy. -**Architecture Invariant:** Glyph snapshot, projection, preview, and Slug preparation methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans, and acquired payloads remain resident in the workspace cache. `prepareAuthoredGlyphCompilation()` may perform complete acquisition before WebGPU initialization so the later aligned atlas request does not repeat that work. +**Architecture Invariant:** Glyph snapshot, projection, preview, and Slug preparation methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans, and acquired payloads remain resident in the workspace cache. Product Grid startup requests bounded `prepareSlugAtlasPage()` roots rather than initiating complete acquisition. **Architecture Invariant:** `shift-font` constructs typed glyph and source-metric interpolation; renderer code only evaluates their flattened transport snapshots. **WHY:** Per-location canvas work stays cheap without moving variation-model construction or value-layout ownership into transport code. **Architecture Invariant:** Package inspection methods are read-only and may run without an open workspace. **WHY:** Electron main/utility code must inspect package identity before deciding whether to reuse, hydrate, relink, or orphan a working document. -**Architecture Invariant:** A prepared authored glyph compilation remains native until `prepareSlugAtlas(alignment)` supplies its device alignment. The resulting Slug atlas or page is consumed once through napi-rs `ReadableStream` chunks. Every font edit invalidates both forms. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. **WHY:** Renderer startup can overlap complete authored compilation without placeholders, while product upload retains one bounded temporary chunk rather than an atlas-sized JavaScript copy or an unbounded IPC queue. +**Architecture Invariant:** A prepared Slug page remains native until it is consumed once through napi-rs `ReadableStream` chunks. Every font edit invalidates unconsumed output. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. **WHY:** Visible roots take priority, fixed pages yield between native calls, and product upload retains one bounded temporary chunk rather than an atlas-sized JavaScript copy or an unbounded IPC queue. `prepareAuthoredGlyphCompilation()` and the complete endpoint remain diagnostic/profiling boundaries, not product startup scheduling. ## Codemap @@ -52,8 +52,8 @@ crates/shift-bridge/ - `NapiNamedInstance` -- explicit product-preset DTO carrying stable identity and a complete external location. - `NapiGlyphProjection` -- compact location-independent glyph backing with reusable interpolation, exact-source exceptions, and Rust-owned `GlyphComponents` relationships. - `NapiSourceMetricsInterpolationSnapshot` -- metric schema, reusable interpolation basis, and ordered source values projected from native source-metric interpolation; derived state, never `.shift` authoring data. -- `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, and aligned resident-section layout. -- `authoredGlyphCompilation` -- one complete location-independent `AuthoredAtlas` prepared before device alignment and consumed by the complete-atlas endpoint. +- `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, scale-preserving `SlugPreviewExtents`, and aligned resident-section layout. +- `authoredGlyphCompilation` -- diagnostic complete location-independent `AuthoredAtlas` prepared before device alignment and consumed by the complete-atlas endpoint. - `SlugAtlasGeneration` -- one aligned native atlas or page consumed by its stream API or released by its discard API. ## How it works @@ -67,7 +67,7 @@ crates/shift-bridge/ 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. 8. `closeWorkspace()` drops the live Rust workspace handle. The utility process retains a clean package-backed SQLite document, but deletes untitled/imported documents and explicitly discarded dirty documents. 9. `exportWorkspace(request)` creates a `FontSaveSnapshot` and exports asynchronously through `shift-backends`. -10. After source open, `prepareAuthoredGlyphCompilation()` acquires all layers and builds the catalog's complete location-independent `AuthoredAtlas` while the renderer starts. `prepareSlugAtlas(alignment)` consumes that prepared compilation and performs only alignment-specific layout; if prewarming has not run, it performs the same complete build synchronously. `prepareSlugAtlasPage(glyphIds, alignment)` independently acquires ordered roots and their indexed component closures for a local edit patch. Each build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. Every font edit invalidates the prepared compilation and any unconsumed generation or patch. Background compilation emits a `[workspace-open]` acquisition/compilation summary; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. +10. The renderer calls `prepareSlugAtlasPage(glyphIds, alignment)` first for current visible roots, then for deterministic fixed directory pages. Every page independently acquires its indexed component closure and reports shared all-source preview extents. Each bounded build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. New visible work supersedes queued complete-residency pages between calls. The complete preparation endpoints remain available to the external profiler; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. ## Type Boundary diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 9efa1951..e31bb574 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -157,6 +157,7 @@ export interface NapiSlugAtlas { bandCount: number weightCount: number layout: NapiSlugLayout + previewExtents: NapiSlugPreviewExtents glyphs: Array weightSets: Array atlasGlyphCount: number @@ -191,6 +192,12 @@ export interface NapiSlugLayout { totalLength: number } +export interface NapiSlugPreviewExtents { + horizontal: number + minimumY: number + maximumY: number +} + export interface NapiSlugSection { offset: number length: number diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 943838a1..88b82ac0 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -130,12 +130,20 @@ pub struct NapiSlugWeightSet { pub source_weight_indices: Vec, } +#[napi(object)] +pub struct NapiSlugPreviewExtents { + pub horizontal: f64, + pub minimum_y: f64, + pub maximum_y: f64, +} + #[napi(object)] pub struct NapiSlugAtlas { pub generation: u32, pub band_count: u32, pub weight_count: u32, pub layout: NapiSlugLayout, + pub preview_extents: NapiSlugPreviewExtents, pub glyphs: Vec, pub weight_sets: Vec, pub atlas_glyph_count: u32, @@ -305,11 +313,18 @@ fn napi_slug_atlas( }) .collect(); + let preview_extents = authored.preview_extents()?; + Ok(NapiSlugAtlas { generation, band_count: authored.atlas().band_count(), weight_count: authored.weight_count(), layout: napi_slug_layout(layout)?, + preview_extents: NapiSlugPreviewExtents { + horizontal: f64::from(preview_extents.horizontal), + minimum_y: f64::from(preview_extents.minimum_y), + maximum_y: f64::from(preview_extents.maximum_y), + }, glyphs, weight_sets, atlas_glyph_count: u32::try_from(statistics.glyph_count) diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index 1a714fe7..5b97c754 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -9,6 +9,7 @@ GPU-independent preprocessing for the experimental Slug home/catalog glyph grid. - **No GPU ownership.** The crate produces deterministic CPU arrays and bytes shared by native `wgpu` benchmarks and Electron WebGPU. Device, queue, surface, and fallback policy belong to consumers. - **Checked ranges.** The reference implementation's unchecked 24-bit offset / 8-bit count packing is not used. Atlas offsets and counts are checked `u32` values; packed byte arithmetic is checked `usize`. - **Bands are location-bound.** The static builder bands one resolved shape. The variable path resolves and re-bands only visible glyphs after every weight update, so current-location membership stays exact without geometry upload. +- **Scale-preserving preview extents.** Every authored root page reports maximum all-source horizontal overhang and vertical bounds. The Grid may enlarge shared cells from those extents, but Slug never fits individual glyphs by changing pixels per em. - **Deterministic topology conversion.** Lines become quadratics. Cubics use the conservative third-derivative error bound from Kurbo's `CubicBez::to_quads`, with a one-font-unit tolerance and equal parameter intervals. Compatible authored sources freeze the maximum subdivision count required by any source so variable topology remains identical. - **No shaping.** The grid addresses glyphs by dense atlas index and does not need a text shaper. - **Command ownership.** `OutlineCommand` is a Slug preprocessing input. No standalone packed-outline storage format exists. @@ -58,7 +59,7 @@ Each glyph owns `band_count` horizontal ranges followed by `band_count` vertical ## Resident variable execution -`build_authored_atlas()` is the product complete-font residency boundary. It delegates to `build_authored_atlas_page()` with every root so complete residency and local edit patches share one compiler and packed layout. Each compilation creates one `GlyphProjectionSet` for its ordered roots and transitive component closure. Weight collection, root addition, component preparation, exact-source discovery, and fallback resolution all read that same immutable set instead of rebuilding projections or variation models. Fallback and exact-source resolved glyphs are retained only within the current root, bounding temporary memory; the complete set is dropped after atlas construction and never survives authored edits. A patch preserves explicit `GlyphId` mapping and excludes unrelated roots. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. +`build_authored_atlas()` remains the complete-font compiler and profiling boundary. The product Grid uses `build_authored_atlas_page()` for a prioritized visible page followed by deterministic fixed directory pages, so visible replacement, cooperative complete residency, and local edits share one compiler and packed layout. Each compilation creates one `GlyphProjectionSet` for its ordered roots and transitive component closure. Weight collection, root addition, component preparation, exact-source discovery, and fallback resolution all read that same immutable set instead of rebuilding projections or variation models. Fallback and exact-source resolved glyphs are retained only within the current root, bounding temporary memory; the complete set is dropped after atlas construction and never survives authored edits. A patch preserves explicit `GlyphId` mapping and excludes unrelated roots. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. `build_authored_atlas_profiled()` and its page counterpart return nested phase durations from the same compiler path. The bridge uses these functions for `prepareSlugAtlas`; setting `SHIFT_PROFILE_SLUG_ATLAS=1` prints acquisition, projection preparation, weight-set collection, component preparation, fallback bounds, exact-source preparation, atlas addition, layout, and total native time without changing the NAPI endpoint. diff --git a/crates/shift-slug/src/lib.rs b/crates/shift-slug/src/lib.rs index 90893dcc..b0ce129e 100644 --- a/crates/shift-slug/src/lib.rs +++ b/crates/shift-slug/src/lib.rs @@ -38,9 +38,10 @@ pub use resident::{ }; pub use variable::{ pack_variable_params, PackedVariableAtlas, PackedVariableChunk, PackedVariableChunks, - VariableAnchorSource, VariableAtlas, VariableAtlasBuilder, VariableComponent, - VariableComponentGlyph, VariableComponentPart, VariableComponentSource, VariableGlyph, - VariableLayout, VariableParams, VariableSource, VariableStatistics, VARIABLE_PARAMS_BYTES, + SlugPreviewExtents, VariableAnchorSource, VariableAtlas, VariableAtlasBuilder, + VariableComponent, VariableComponentGlyph, VariableComponentPart, VariableComponentSource, + VariableGlyph, VariableLayout, VariableParams, VariableSource, VariableStatistics, + VARIABLE_PARAMS_BYTES, }; /// Shader source shared by native `wgpu` and Electron WebGPU consumers. diff --git a/crates/shift-slug/src/resident.rs b/crates/shift-slug/src/resident.rs index 0f45d643..92931741 100644 --- a/crates/shift-slug/src/resident.rs +++ b/crates/shift-slug/src/resident.rs @@ -5,7 +5,7 @@ use shift_font::{CoreError, Font, GlyphId, GlyphProjection, GlyphProjectionSet}; use crate::{ AuthoredAtlasBuilder, AuthoredGlyph, AuthoredSlugError, AuthoredWeightSet, SlugError, - VariableAtlas, + SlugPreviewExtents, VariableAtlas, }; /// One authored root glyph and every resident atlas glyph it may select. @@ -48,6 +48,24 @@ impl AuthoredAtlasPage { pub fn weight_count(&self) -> u32 { self.weight_count } + + /// Shared all-source preview overflow for this ordered root page. + pub fn preview_extents(&self) -> Result { + let glyph_indices = self + .glyphs + .iter() + .flat_map(|glyph| { + std::iter::once(glyph.authored.default_glyph).chain( + glyph + .authored + .exact_sources + .iter() + .map(|source| source.glyph_index), + ) + }) + .collect::>(); + self.atlas.preview_extents(&glyph_indices) + } } /// A complete-font atlas is one page containing every authored root glyph. diff --git a/crates/shift-slug/src/variable.rs b/crates/shift-slug/src/variable.rs index dda18abc..d950a902 100644 --- a/crates/shift-slug/src/variable.rs +++ b/crates/shift-slug/src/variable.rs @@ -39,6 +39,14 @@ pub struct VariableGlyph { pub source_count: u32, } +/// Font-space overflow shared by every preview cell at one authored revision. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SlugPreviewExtents { + pub horizontal: f32, + pub minimum_y: f32, + pub maximum_y: f32, +} + /// One source contribution for a variable glyph. #[repr(C)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -235,6 +243,66 @@ impl VariableAtlas { } } + /// Derives shared preview overflow without changing the existing pixels-per-em scale. + pub fn preview_extents(&self, glyph_indices: &[u32]) -> Result { + let mut horizontal = 0.0_f32; + let mut minimum_y = f32::INFINITY; + let mut maximum_y = f32::NEG_INFINITY; + + for glyph_index in glyph_indices { + let glyph = *self + .glyphs + .get(*glyph_index as usize) + .ok_or(SlugError::GlyphIndexOutOfRange(*glyph_index))?; + let advance_glyph = match component_glyph_index(glyph) { + Some(component_index) => { + let component = self + .component_glyphs + .get(component_index) + .ok_or(SlugError::LengthOverflow)?; + *self + .glyphs + .get(component.root_glyph_index as usize) + .ok_or(SlugError::LengthOverflow)? + } + None => glyph, + }; + let advance_start = advance_glyph.source_start as usize; + let advance_end = advance_start + .checked_add(advance_glyph.source_count as usize) + .ok_or(SlugError::LengthOverflow)?; + let minimum_advance = self + .source_advances + .get(advance_start..advance_end) + .ok_or(SlugError::LengthOverflow)? + .iter() + .copied() + .fold(f32::INFINITY, f32::min); + let minimum_advance = if minimum_advance.is_finite() { + minimum_advance + } else { + 0.0 + }; + + horizontal = horizontal + .max((-glyph.bounds.min_x).max(0.0)) + .max((glyph.bounds.max_x - minimum_advance).max(0.0)); + minimum_y = minimum_y.min(glyph.bounds.min_y); + maximum_y = maximum_y.max(glyph.bounds.max_y); + } + + if minimum_y.is_infinite() { + minimum_y = 0.0; + maximum_y = 0.0; + } + + Ok(SlugPreviewExtents { + horizontal, + minimum_y, + maximum_y, + }) + } + /// Resolves the common two-source model with the compute shader's f32 arithmetic. pub fn resolve_glyph( &self, diff --git a/crates/shift-slug/tests/atlas.rs b/crates/shift-slug/tests/atlas.rs index 4cda6130..123e5b7d 100644 --- a/crates/shift-slug/tests/atlas.rs +++ b/crates/shift-slug/tests/atlas.rs @@ -84,6 +84,39 @@ fn diagonal_lines_receive_the_reference_epsilon() { assert!((displacement - LINE_EPSILON).abs() < 0.0001); } +#[test] +fn preview_extents_preserve_scale_for_all_source_overflow() { + let mut builder = VariableAtlasBuilder::new(8).unwrap(); + let glyph_index = builder + .add_curve_glyph_with_sources( + [Curve { + p0: Point::new(-40.0, -200.0), + p1: Point::new(200.0, 900.0), + p2: Point::new(500.0, 100.0), + }], + 0, + [( + 1, + vec![Curve { + p0: Point::new(-60.0, -250.0), + p1: Point::new(300.0, 1000.0), + p2: Point::new(700.0, 120.0), + }], + )], + ) + .unwrap(); + builder + .set_glyph_source_advances(glyph_index, [600.0, 650.0]) + .unwrap(); + let atlas = builder.finish(); + + let extents = atlas.preview_extents(&[glyph_index]).unwrap(); + + assert_eq!(extents.horizontal, 100.0); + assert_eq!(extents.minimum_y, -250.0); + assert_eq!(extents.maximum_y, 1000.0); +} + #[test] fn every_band_range_addresses_its_glyph_curves() { let mut builder = AtlasBuilder::new(8).unwrap(); diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index 545ff4bb..a84f5702 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -172,6 +172,7 @@ export interface SlugAtlas { bandCount: number weightCount: number layout: SlugLayout + previewExtents: SlugPreviewExtents glyphs: Array weightSets: Array atlasGlyphCount: number @@ -206,6 +207,12 @@ export interface SlugLayout { totalLength: number } +export interface SlugPreviewExtents { + horizontal: number + minimumY: number + maximumY: number +} + export interface SlugSection { offset: number length: number diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index b13ea8e9..47fd198b 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -20,6 +20,7 @@ export type { SlugExactSource, SlugGlyph, SlugLayout, + SlugPreviewExtents, SlugSection, SlugWeightSet, TranslatePointsIntent, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 97bf1ba8..301b4d34 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -88,6 +88,7 @@ export type { SlugExactSource, SlugGlyph, SlugLayout, + SlugPreviewExtents, SlugSection, SlugWeightSet, TranslatePointsIntent, From 37dfa10b97848c2a94bd1648fc252c749852a392 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 14:09:07 +0000 Subject: [PATCH 2/8] Cover variable Grid replacement in E2E --- apps/desktop/e2e/gpu.spec.ts | 79 +++++++++++++++++++++++------------ apps/desktop/e2e/home.spec.ts | 5 +++ 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index 529807c0..1d86d80a 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -1,4 +1,5 @@ import type { ElectronApplication, Locator, Page } from "@playwright/test"; +import type { AxisId, SourceId } from "@shift/types"; import { test, expect, navigateToEditor } from "./fixtures/perfApp"; const RESIDENT_GPU_ERROR = /resident glyph (device lost|frame failed|initialization failed)/i; @@ -217,10 +218,11 @@ test.describe("Resident catalog GPU", () => { await page.getByRole("button", { name: "Display all glyphs" }).click(); await page.waitForURL(/#\/home/); await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugFrameSubmits), { - timeout: 30_000, - }) - .toBe("1"); + .poll( + () => page.evaluate(() => Number(document.documentElement.dataset.slugFrameSubmits ?? "0")), + { timeout: 30_000 }, + ) + .toBeGreaterThanOrEqual(1); await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); const recoveryDuration = performance.now() - returnStarted; const refreshDuration = performance.now() - editStarted; @@ -302,25 +304,21 @@ test.describe("Resident catalog GPU", () => { page, }) => { const glyphCanvas = await preparePagedGrid(electronApp, page); + const variable = await createVariableDesignspace(page); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); await trackGridTransitions(page); await trackSlugAtlasLoads(page); - await page.evaluate(async () => { + await page.evaluate(async ({ axisId, sourceId }) => { const workspace = window.shift; - const font = workspace?.font; - const source = font?.sources.find((candidate) => candidate.id !== font.defaultSource.id); - if (!workspace || !font || !source) throw new Error("Expected a non-default source"); - - workspace.editor.setDesignLocation( - new Map( - font - .getAxes() - .map((axis) => [axis.id, source.location.values[axis.id] ?? axis.default] as const), - ), - ); - font.deleteSource(source.id); - await font.editCoordinator.settled(); - }); + if (!workspace) throw new Error("Expected workspace"); + + workspace.editor.setDesignLocation(new Map([[axisId, 900]])); + workspace.font.deleteSource(sourceId); + await workspace.font.editCoordinator.settled(); + }, variable); await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { timeout: 30_000, @@ -341,20 +339,22 @@ test.describe("Resident catalog GPU", () => { page, }) => { const glyphCanvas = await preparePagedGrid(electronApp, page); + const variable = await createVariableDesignspace(page); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); await trackGridTransitions(page); - const deletedAxis = await page.evaluate(async () => { + const deletedAxis = await page.evaluate(async ({ axisId }) => { const workspace = window.shift; - const axis = workspace?.font.getAxes()[0]; - if (!workspace || !axis) throw new Error("Expected a variable axis"); + if (!workspace) throw new Error("Expected workspace"); - const nonDefault = axis.maximum === axis.default ? axis.minimum : axis.maximum; - workspace.editor.setDesignLocation(new Map([[axis.id, nonDefault ?? axis.default]])); + workspace.editor.setDesignLocation(new Map([[axisId, 750]])); await new Promise((resolve) => requestAnimationFrame(() => resolve())); - workspace.font.deleteAxis(axis.id); + workspace.font.deleteAxis(axisId); await workspace.font.editCoordinator.settled(); - return axis.id; - }); + return axisId; + }, variable); await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { timeout: 30_000, @@ -413,6 +413,31 @@ test.describe("Resident catalog GPU", () => { }); }); +async function createVariableDesignspace( + page: Page, +): Promise<{ axisId: AxisId; sourceId: SourceId }> { + return page.evaluate(async () => { + const font = window.shift?.font; + if (!font) throw new Error("Expected font"); + + const axisId = font.createAxis({ + tag: "wght", + name: "Weight", + role: "external", + axisType: "continuous", + minimum: 100, + default: 400, + maximum: 900, + labels: [], + hidden: false, + }); + await font.editCoordinator.settled(); + const sourceId = font.createSource("Bold", { values: { [axisId]: 900 } }); + await font.editCoordinator.settled(); + return { axisId, sourceId }; + }); +} + async function preparePagedGrid(electronApp: ElectronApplication, page: Page): Promise { await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); await electronApp.evaluate(async ({ BrowserWindow }) => { diff --git a/apps/desktop/e2e/home.spec.ts b/apps/desktop/e2e/home.spec.ts index 0a20aa9a..4835995d 100644 --- a/apps/desktop/e2e/home.spec.ts +++ b/apps/desktop/e2e/home.spec.ts @@ -29,6 +29,11 @@ test.describe("Home view", () => { test("keeps the resident grid when returning from the editor", async ({ page }) => { const scrollViewport = page.getByLabel("Glyph catalog"); const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); + await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + await afterNextPaint(page); const initialSize = await glyphCanvas.evaluate((canvas) => ({ width: canvas.width, height: canvas.height, From 1f374964574833139b07a05679010170ba5424ba Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 10:23:51 -0400 Subject: [PATCH 3/8] Allow multiple distant topology frames --- apps/desktop/e2e/gpu.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index 1d86d80a..13fde0c9 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -288,10 +288,11 @@ test.describe("Resident catalog GPU", () => { element.scrollTop = element.scrollHeight; }); await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugFrameSubmits), { - timeout: 30_000, - }) - .toBe("1"); + .poll( + () => page.evaluate(() => Number(document.documentElement.dataset.slugFrameSubmits ?? "0")), + { timeout: 30_000 }, + ) + .toBeGreaterThanOrEqual(1); await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); const scrollDuration = performance.now() - scrollStarted; From 60e31941c4e8df1417cf6006ab979836d9a8108e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 15:58:29 +0000 Subject: [PATCH 4/8] Cache fixed Grid atlas pages --- apps/desktop/e2e/gpu.spec.ts | 6 +- apps/desktop/src/main/docs/DOCS.md | 3 +- .../src/main/workspace/WorkspaceProcess.ts | 5 +- .../components/home/GlyphCatalogController.ts | 56 +- .../graphics/backends/ResidentGlyphLayer.ts | 104 ++- .../renderer/src/lib/graphics/docs/DOCS.md | 10 +- .../src/renderer/src/lib/slug/SlugRenderer.ts | 37 +- .../src/lib/workspace/WorkspaceClient.ts | 25 +- .../lib/workspace/WorkspaceEditCoordinator.ts | 16 +- .../renderer/src/testing/workspaceStack.ts | 1 + .../src/renderer/src/types/glyphCatalog.ts | 8 + .../shared/workspace/PortByteStream.test.ts | 16 + .../src/shared/workspace/PortByteStream.ts | 44 +- apps/desktop/src/shared/workspace/protocol.ts | 27 +- apps/desktop/src/utility/workspace.ts | 6 +- .../src/utility/workspace/CachedAtlas.test.ts | 249 ++++++ .../src/utility/workspace/CachedAtlas.ts | 710 ++++++++++++++++++ .../utility/workspace/WorkspaceHost.test.ts | 34 +- .../src/utility/workspace/WorkspaceHost.ts | 247 +++++- apps/desktop/src/utility/workspace/types.ts | 94 ++- crates/shift-bridge/__test__/index.spec.mjs | 8 + crates/shift-bridge/docs/DOCS.md | 5 +- crates/shift-bridge/index.d.ts | 2 + crates/shift-bridge/src/bridge.rs | 6 + crates/shift-slug/docs/DOCS.md | 2 +- crates/shift-workspace/docs/DOCS.md | 3 + crates/shift-workspace/src/workspace.rs | 15 + .../shift-workspace/tests/workspace_test.rs | 17 + packages/types/src/bridge/generated.ts | 2 + 29 files changed, 1637 insertions(+), 121 deletions(-) create mode 100644 apps/desktop/src/utility/workspace/CachedAtlas.test.ts create mode 100644 apps/desktop/src/utility/workspace/CachedAtlas.ts diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index 13fde0c9..e4897b3d 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -535,13 +535,13 @@ async function trackSlugAtlasLoads(page: Page): Promise { ); return originalCompletePrepare(alignment); }; - coordinator.prepareSlugAtlasPage = async (glyphIds, alignment) => { + coordinator.prepareSlugAtlasPage = async (request) => { const counts = JSON.parse( document.documentElement.dataset.slugPatchRootCounts ?? "[]", ) as number[]; - counts.push(glyphIds.length); + counts.push(request.glyphIds.length); document.documentElement.dataset.slugPatchRootCounts = JSON.stringify(counts); - return originalPatchPrepare(glyphIds, alignment); + return originalPatchPrepare(request); }; }); } diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 898790f2..b1d00125 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -11,6 +11,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. - **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty documents are pruned. - **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. +- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. ## Codemap @@ -62,7 +63,7 @@ On macOS, closing the last window leaves Shift running. A later Dock activation File -> New asks `WorkspaceManager.createUntitled()` for a session. The launcher prepares an idle utility process, so File -> Open overlaps process startup with `showOpenFontDialog()` before asking `WorkspaceManager.openPath(path)`. -For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. Main does not start monolithic Slug preparation: the renderer requests the current visible root page first, presents that complete frame, and cooperatively fills fixed-size catalog pages afterward. +For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. Main does not start monolithic Slug preparation: the renderer requests every fixed page intersecting the current viewport first, presents that set atomically, and cooperatively fills remaining pages afterward. The utility serves validated Zstd cache hits through the same bounded stream contract or compiles a native miss and stages it for atomic publication. ### Window Attachment diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index d3d0073d..57b1f8e8 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -34,13 +34,14 @@ export class WorkspaceProcess { * Forks the workspace utility process if it is not already running. * * @param documentsRoot - Directory the utility process owns for working - * documents; passed as the process's only argument. + * documents; its sibling derived-cache directory owns disposable atlases. */ start(documentsRoot: string): void { if (this.#process) return; const entryPoint = path.join(__dirname, "workspace.js"); - const proc = utilityProcess.fork(entryPoint, [documentsRoot], { + const atlasCacheRoot = path.join(path.dirname(documentsRoot), "derived-cache", "slug-atlases"); + const proc = utilityProcess.fork(entryPoint, [documentsRoot, atlasCacheRoot], { serviceName: "Shift Workspace", stdio: "pipe", }); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 39919147..6f28fc09 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -11,6 +11,7 @@ import { FrameHandler } from "@/lib/editor/rendering/FrameHandler"; import { parseCssColor } from "@/lib/editor/rendering/markers/color"; import { ResidentGlyphLayer } from "@/lib/graphics/backends/ResidentGlyphLayer"; import type { + GlyphCatalogAtlasPage, GlyphCatalogControllerFrame, GlyphCatalogFrame, GlyphCatalogItem, @@ -42,6 +43,7 @@ export class GlyphCatalogController { readonly #resizeObserver: ResizeObserver; readonly #fontEffect: Effect; readonly #invalidGlyphIds = new Set(); + readonly #replacementPageIndices = new Set(); #targetFrame: GridFrame | null = null; #activeFrame: GridFrame | null = null; @@ -182,6 +184,12 @@ export class GlyphCatalogController { } } + this.#replacementPageIndices.clear(); + for (const glyphId of this.#invalidGlyphIds) { + const pageIndex = this.#pageIndex(glyphId); + if (pageIndex !== null) this.#replacementPageIndices.add(pageIndex); + } + this.#visibleBuild?.abort(new Error("resident visible frame changed")); this.#completeBuild?.abort(new Error("resident complete atlas changed")); this.#needsRedraw = true; @@ -267,7 +275,8 @@ export class GlyphCatalogController { this.#updateFullyResident(); try { - const pageExtents = await layer.loadPatch(glyphIds, visibleBuild.signal); + const pageRequests = this.#pageRequests(glyphIds); + const pageExtents = await layer.loadPages(pageRequests, visibleBuild.signal); if (this.#disposed || this.#visibleBuild !== visibleBuild || visibleBuild.signal.aborted) { return; } @@ -281,7 +290,9 @@ export class GlyphCatalogController { ); this.#targetFrame = { ...latestTarget, previewExtents: targetExtents }; this.#activeFrame = { ...latestTarget, previewExtents: presentedExtents }; - for (const glyphId of glyphIds) this.#invalidGlyphIds.delete(glyphId); + for (const request of pageRequests) { + for (const glyphId of request.glyphIds) this.#invalidGlyphIds.delete(glyphId); + } this.#onPreviewExtentsChange(presentedExtents); this.#needsRedraw = true; this.#updateFullyResident(); @@ -337,7 +348,11 @@ export class GlyphCatalogController { ); if (!needsReplacement) continue; - const pageExtents = await this.#layer.loadPatch(pageGlyphIds, completeBuild.signal); + const pageIndex = start / ATLAS_PAGE_ROOT_COUNT; + const pageExtents = await this.#layer.loadPages( + [this.#pageRequest(pageIndex)], + completeBuild.signal, + ); if (completeBuild.signal.aborted) break; const latestTarget = this.#targetFrame; @@ -368,6 +383,37 @@ export class GlyphCatalogController { if (completeBuild.signal.aborted) void this.#refreshVisible(); } + #pageRequests(glyphIds: readonly GlyphId[]): GlyphCatalogAtlasPage[] { + const pageIndices = new Set(); + for (const glyphId of glyphIds) { + const pageIndex = this.#pageIndex(glyphId); + if (pageIndex !== null) pageIndices.add(pageIndex); + } + + return [...pageIndices] + .sort((left, right) => left - right) + .map((pageIndex) => this.#pageRequest(pageIndex)); + } + + #pageRequest(pageIndex: number): GlyphCatalogAtlasPage { + const start = pageIndex * ATLAS_PAGE_ROOT_COUNT; + return { + glyphIds: this.#fontGlyphIds.slice(start, start + ATLAS_PAGE_ROOT_COUNT), + pageIndex, + pageCount: this.#pageCount(), + replacementPageIndices: [...this.#replacementPageIndices].sort((left, right) => left - right), + }; + } + + #pageIndex(glyphId: GlyphId): number | null { + const glyphIndex = this.#fontGlyphIds.indexOf(glyphId); + return glyphIndex < 0 ? null : Math.floor(glyphIndex / ATLAS_PAGE_ROOT_COUNT); + } + + #pageCount(): number { + return Math.ceil(this.#fontGlyphIds.length / ATLAS_PAGE_ROOT_COUNT); + } + #handleDeviceLoss(reason: string): void { if (this.#disposed) return; console.error("resident glyph device lost", reason); @@ -381,6 +427,10 @@ export class GlyphCatalogController { this.#activeFrame = null; this.#invalidGlyphIds.clear(); for (const glyphId of this.#fontGlyphIds) this.#invalidGlyphIds.add(glyphId); + this.#replacementPageIndices.clear(); + for (let pageIndex = 0; pageIndex < this.#pageCount(); pageIndex += 1) { + this.#replacementPageIndices.add(pageIndex); + } this.#glyphCanvas.dataset.fullyResident = "false"; this.#glyphCanvas.dataset.gridReadiness = "Unavailable" satisfies GridReadiness; this.#firstFrameStarted = false; diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts index 1ab1c6ea..079eed08 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts @@ -1,4 +1,6 @@ import type { GlyphId, SlugPreviewExtents } from "@shift/types"; +import type { SlugAtlasOrigin } from "@shared/workspace/protocol"; +import type { GlyphCatalogAtlasPage } from "@/types/glyphCatalog"; import type { GlyphPreviewFrame } from "@/types/glyphPreview"; import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; import { SlugAtlas } from "@/lib/slug/SlugAtlas"; @@ -7,10 +9,10 @@ import { SlugRenderer } from "@/lib/slug/SlugRenderer"; const COPY_ALIGNMENT = 256; const UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024; const PREFERRED_ATLAS_BYTES = 256 * 1024 * 1024; -const PREFERRED_PATCH_BYTES = 64 * 1024 * 1024; +const PREFERRED_PAGE_BYTES = 64 * 1024 * 1024; const REQUIRED_STORAGE_BUFFERS = 8; -/** Complete resident glyph-preview surface with incremental edit patches. */ +/** Complete resident glyph-preview surface with atomically replaceable fixed pages. */ export class ResidentGlyphLayer { readonly #renderer: SlugRenderer; readonly #device: GPUDevice; @@ -92,55 +94,76 @@ export class ResidentGlyphLayer { } } - async loadPatch(glyphIds: readonly GlyphId[], signal: AbortSignal): Promise { - if (glyphIds.length === 0) { + async loadPages( + pages: readonly GlyphCatalogAtlasPage[], + signal: AbortSignal, + ): Promise { + if (pages.length === 0) { return { horizontal: 0, minimumY: 0, maximumY: 0 }; } + const atlases: SlugAtlas[] = []; let preparedGeneration: number | null = null; + let preparedOrigin: SlugAtlasOrigin | null = null; let atlas: SlugAtlas | null = null; + let previewExtents: SlugPreviewExtents = { + horizontal: 0, + minimumY: 0, + maximumY: 0, + }; try { - const descriptor = await this.#edits.prepareSlugAtlasPage(glyphIds, this.#alignment); - preparedGeneration = descriptor.generation; - throwIfAborted(signal); - - if (descriptor.layout.totalLength > PREFERRED_PATCH_BYTES) { - console.warn("resident glyph atlas patch exceeds preferred size", { - bytes: descriptor.layout.totalLength, - preferredBytes: PREFERRED_PATCH_BYTES, + for (const page of pages) { + const descriptor = await this.#edits.prepareSlugAtlasPage({ + ...page, + alignment: this.#alignment, }); - } + preparedGeneration = descriptor.generation; + preparedOrigin = descriptor.origin; + throwIfAborted(signal); + + if (descriptor.layout.totalLength > PREFERRED_PAGE_BYTES) { + console.warn("resident glyph atlas page exceeds preferred size", { + bytes: descriptor.layout.totalLength, + preferredBytes: PREFERRED_PAGE_BYTES, + }); + } - atlas = SlugAtlas.create(descriptor, this.#device, this.#maximumBindingSize); - const activeAtlas = atlas; - const totalLength = await this.#edits.streamSlugAtlasPage( - descriptor.generation, - UPLOAD_CHUNK_BYTES, - (offset, bytes) => { - throwIfAborted(signal); - activeAtlas.write(this.#device.queue, offset, bytes); - }, - ); - preparedGeneration = null; - throwIfAborted(signal); - if (totalLength !== descriptor.layout.totalLength) { - throw new Error( - `resident glyph patch stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, + atlas = SlugAtlas.create(descriptor, this.#device, this.#maximumBindingSize); + const activeAtlas = atlas; + const totalLength = await this.#edits.streamSlugAtlasPage( + descriptor.generation, + descriptor.origin, + UPLOAD_CHUNK_BYTES, + (offset, bytes) => { + throwIfAborted(signal); + activeAtlas.write(this.#device.queue, offset, bytes); + }, ); + preparedGeneration = null; + preparedOrigin = null; + throwIfAborted(signal); + if (totalLength !== descriptor.layout.totalLength) { + throw new Error( + `resident glyph page stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, + ); + } + + atlases.push(atlas); + atlas = null; + previewExtents = mergePreviewExtents(previewExtents, descriptor.previewExtents); } - const loadedAtlas = atlas; - atlas = null; - this.#renderer.loadPage(loadedAtlas); - return descriptor.previewExtents; + this.#renderer.loadPages(atlases); + return previewExtents; } catch (error) { atlas?.destroy(); - if (preparedGeneration !== null) { + for (const uploadedAtlas of atlases) uploadedAtlas.destroy(); + if (preparedGeneration !== null && preparedOrigin !== null) { try { - await this.#edits.discardSlugAtlasPage(preparedGeneration); + await this.#edits.discardSlugAtlasPage(preparedGeneration, preparedOrigin); } catch (discardError) { - console.error("failed to release rejected resident glyph atlas patch", discardError); + console.error("failed to release rejected resident glyph atlas page", discardError); } } throw error; @@ -168,6 +191,17 @@ export class ResidentGlyphLayer { } } +function mergePreviewExtents( + current: SlugPreviewExtents, + next: SlugPreviewExtents, +): SlugPreviewExtents { + return { + horizontal: Math.max(current.horizontal, next.horizontal), + minimumY: Math.min(current.minimumY, next.minimumY), + maximumY: Math.max(current.maximumY, next.maximumY), + }; +} + function throwIfAborted(signal: AbortSignal): void { if (!signal.aborted) return; diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index a1a6ed79..0ffeec5e 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -10,9 +10,9 @@ Renderer vector-path values and the accelerated marker-layer backend for editor - **Architecture Invariant:** `ResidentGlyphLayer` is the generic catalog-preview boundary. It owns one WebGPU adapter/device/context and independently replaceable authored root pages; Slug names and packed-layout knowledge remain behind that backend rather than entering React catalog components. -- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw because Chromium may discard the WebGPU canvas presentation. Initial readiness means the current visible page is complete and its submitted frame completed; complete residency is tracked separately while fixed root pages fill cooperatively. +- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw because Chromium may discard the WebGPU canvas presentation. Initial readiness means every fixed page intersecting the current viewport is complete and the submitted frame completed; complete residency is tracked separately while remaining pages fill cooperatively. -- **Architecture Invariant:** Atlas invalidation never removes a presented root before its replacement page is uploaded. Axis, source, mapping, directory, and structural changes retain the prior frame, prioritize every visible root at the new authored revision, swap that frame atomically, and then replace offscreen fixed pages. One bounded native page may occupy the utility lane; no monolithic complete-font request blocks later visible work. +- **Architecture Invariant:** Atlas invalidation never removes a presented root before all visible replacement pages are uploaded. Axis, source, mapping, directory, and structural changes retain the prior frame, prioritize every fixed page intersecting the viewport at the new authored revision, install that page set in one synchronous glyph-map replacement, and then replace offscreen pages. One bounded native or cached page may occupy the utility lane; no monolithic complete-font request blocks later visible work. - **Architecture Invariant:** Preview scale remains metrics-derived. `SlugPreviewExtents` expands every cell from the font-wide all-source overflow without changing pixels per em; extents grow as pages arrive and stale extents remain safe during replacement. @@ -32,7 +32,7 @@ graphics/ canvasText.ts — width-constrained Canvas2D label fitting backends/ MarkerLayer.ts — WebGL context: REGL init, instance buffer management, draw command - ResidentGlyphLayer.ts — WebGPU catalog device, complete upload, edit patches, draw, and teardown + ResidentGlyphLayer.ts — WebGPU catalog device, fixed-page uploads, atomic replacement, draw, and teardown ``` Supporting files live in the editor rendering module: @@ -55,7 +55,7 @@ editor/rendering/markers/ - `MarkerLayer` -- WebGL context wrapper. Manages REGL instance, instance buffer, and draw command. Provides `resizeCanvas`, `draw`, `clear`, `destroy`, and `isAvailable`. -- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It retains one device/context and prepares, streams, and atomically installs independently replaceable native Slug root pages. +- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It retains one device/context and prepares, streams, and atomically installs independently replaceable native or cached Slug root pages. - `MarkerInstance` -- logical representation of one marker shape. The current marker path packs directly into a `Float32Array` for zero steady-state allocation. @@ -77,7 +77,7 @@ editor/rendering/markers/ ### Resident catalog lifecycle -`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency prepares only roots intersecting the current viewport, submits that complete page, and then `#refreshComplete` fills deterministic directory pages while yielding between native calls. Local edits and global axis/source changes leave the active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes the newly visible roots. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. +`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency selects every deterministic 256-root directory page intersecting the current viewport, uploads the complete set, and then `#refreshComplete` fills remaining pages while yielding between requests. `SlugRenderer.loadPages` constructs all GPU page resources before synchronously replacing glyph mappings, so a viewport crossing a page boundary cannot expose a mixed set. Local edits and global axis/source changes leave active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes newly visible pages. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. Every page reports all-source `SlugPreviewExtents`. The controller monotonically merges those bounds during the active generation, and `GlyphCatalogLayout` expands shared cell width, preview height, and row pitch using the existing metrics-derived pixels-per-em ratio. Oversized glyphs therefore retain the same scale rather than being individually fitted or clipped to the metrics box. diff --git a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts index 93df34fe..316c15ea 100644 --- a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts +++ b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts @@ -4,7 +4,7 @@ import { SlugAtlas } from "./SlugAtlas"; import { SlugAtlasPage } from "./SlugAtlasPage"; import { SlugRendererPipelines } from "./SlugRendererResources"; -/** Complete Slug atlas renderer with independently replaceable glyph patches. */ +/** Complete Slug atlas renderer with independently replaceable fixed pages. */ export class SlugRenderer { readonly #device: GPUDevice; readonly #context: GPUCanvasContext; @@ -33,19 +33,40 @@ export class SlugRenderer { } loadPage(atlas: SlugAtlas): void { + this.loadPages([atlas]); + } + + /** Commits a prepared page set in one synchronous glyph-map replacement. */ + loadPages(atlases: readonly SlugAtlas[]): void { if (this.#disposed) { - atlas.destroy(); + for (const atlas of atlases) atlas.destroy(); return; } - const page = new SlugAtlasPage(this.#device, atlas, this.#pipelines); + const pages: SlugAtlasPage[] = []; + try { + for (const atlas of atlases) { + pages.push(new SlugAtlasPage(this.#device, atlas, this.#pipelines)); + } + } catch (error) { + for (const page of pages) page.destroy(); + for (const atlas of atlases.slice(pages.length)) atlas.destroy(); + throw error; + } + + const nextPageByGlyph = new Map(this.#pageByGlyph); const replaced = new Set(); - for (const glyphId of page.glyphIds) { - const previous = this.#pageByGlyph.get(glyphId); - if (previous) replaced.add(previous); - this.#pageByGlyph.set(glyphId, page); + for (const page of pages) { + for (const glyphId of page.glyphIds) { + const previous = nextPageByGlyph.get(glyphId); + if (previous) replaced.add(previous); + nextPageByGlyph.set(glyphId, page); + } } - this.#pages.add(page); + + for (const page of pages) this.#pages.add(page); + this.#pageByGlyph.clear(); + for (const [glyphId, page] of nextPageByGlyph) this.#pageByGlyph.set(glyphId, page); this.#removeUnusedPages(replaced); } diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts index 0ddd0e4b..0d0177e4 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts @@ -1,12 +1,15 @@ import { Channel, domPortTransport, type Transport } from "@shared/workspace/channel"; import { PortByteStream } from "@shared/workspace/PortByteStream"; import type { + SlugAtlasOrigin, SyncCallMap, SyncEventMap, WorkspaceDocumentState, WorkspaceExportResult, WorkspaceGlyphSnapshot, WorkspaceGlyphSnapshotRequest, + WorkspaceSlugAtlas, + WorkspaceSlugAtlasPageRequest, WorkspaceSnapshot, } from "@shared/workspace/protocol"; import type { ShiftHost } from "@shared/host/ShiftHost"; @@ -201,14 +204,11 @@ export class WorkspaceClient { return this.#require().call("workspace.slugAtlasPrepare", { alignment }); } - /** Builds one ordered root-glyph page behind committed workspace edits. */ - async prepareSlugAtlasPage(glyphIds: readonly GlyphId[], alignment: number): Promise { + /** Opens or builds one deterministic root-glyph page behind committed edits. */ + async prepareSlugAtlasPage(request: WorkspaceSlugAtlasPageRequest): Promise { await this.connect(); - return this.#require().call("workspace.slugAtlasPagePrepare", { - glyphIds: [...glyphIds], - alignment, - }); + return this.#require().call("workspace.slugAtlasPagePrepare", request); } /** Writes one prepared Slug generation through bounded, ordered chunks. */ @@ -238,6 +238,7 @@ export class WorkspaceClient { /** Writes one prepared page through bounded, ordered chunks. */ async streamSlugAtlasPage( generation: number, + origin: SlugAtlasOrigin, maximumLength: number, write: (offset: number, bytes: Uint8Array) => void, ): Promise { @@ -248,9 +249,11 @@ export class WorkspaceClient { try { const [, totalLength] = await Promise.all([ - this.#require().call("workspace.slugAtlasPageStream", { generation, maximumLength }, [ - ports.port2, - ]), + this.#require().call( + "workspace.slugAtlasPageStream", + { generation, origin, maximumLength }, + [ports.port2], + ), stream.receive(write), ]); return totalLength; @@ -267,10 +270,10 @@ export class WorkspaceClient { } /** Releases a prepared page that was rejected before streaming. */ - async discardSlugAtlasPage(generation: number): Promise { + async discardSlugAtlasPage(generation: number, origin: SlugAtlasOrigin): Promise { await this.connect(); - await this.#require().call("workspace.slugAtlasPageDiscard", { generation }); + await this.#require().call("workspace.slugAtlasPageDiscard", { generation, origin }); } /** diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index df3a10a1..71e59ac6 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -10,8 +10,11 @@ import type { import type { WorkspaceDocumentState, WorkspaceExportResult, + SlugAtlasOrigin, WorkspaceGlyphSnapshot, WorkspaceGlyphSnapshotRequest, + WorkspaceSlugAtlas, + WorkspaceSlugAtlasPageRequest, } from "@shared/workspace/protocol"; import { signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; import type { FontStore, WorkspaceCommitState } from "@/lib/model/FontStore"; @@ -195,9 +198,9 @@ export class WorkspaceEditCoordinator { return this.#withFlush(() => this.#workspace.prepareSlugAtlas(alignment)); } - /** Builds one ordered root-glyph page behind every pending edit. */ - prepareSlugAtlasPage(glyphIds: readonly GlyphId[], alignment: number): Promise { - return this.#withFlush(() => this.#workspace.prepareSlugAtlasPage(glyphIds, alignment)); + /** Opens or builds one deterministic root-glyph page behind every pending edit. */ + prepareSlugAtlasPage(request: WorkspaceSlugAtlasPageRequest): Promise { + return this.#withFlush(() => this.#workspace.prepareSlugAtlasPage(request)); } /** Streams a prepared generation without constructing a contiguous JS atlas. */ @@ -212,11 +215,12 @@ export class WorkspaceEditCoordinator { /** Streams one prepared page without constructing a contiguous JS atlas. */ streamSlugAtlasPage( generation: number, + origin: SlugAtlasOrigin, maximumLength: number, write: (offset: number, bytes: Uint8Array) => void, ): Promise { return this.#withFlush(() => - this.#workspace.streamSlugAtlasPage(generation, maximumLength, write), + this.#workspace.streamSlugAtlasPage(generation, origin, maximumLength, write), ); } @@ -226,8 +230,8 @@ export class WorkspaceEditCoordinator { } /** Releases one rejected prepared page. */ - discardSlugAtlasPage(generation: number): Promise { - return this.#withFlush(() => this.#workspace.discardSlugAtlasPage(generation)); + discardSlugAtlasPage(generation: number, origin: SlugAtlasOrigin): Promise { + return this.#withFlush(() => this.#workspace.discardSlugAtlasPage(generation, origin)); } /** diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index 866fff79..230636ae 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -31,6 +31,7 @@ export function createWorkspaceStack(): WorkspaceStack { const shellLane = new MessageChannel(); new WorkspaceHost({ documentsRoot, + atlasCacheRoot: join(documentsRoot, "atlas-cache"), shell: nodePortTransport(shellLane.port2), portTransport: (port) => nodePortTransport(port as NodeMessagePort), }).start(); diff --git a/apps/desktop/src/renderer/src/types/glyphCatalog.ts b/apps/desktop/src/renderer/src/types/glyphCatalog.ts index 7d1b3010..e3f71564 100644 --- a/apps/desktop/src/renderer/src/types/glyphCatalog.ts +++ b/apps/desktop/src/renderer/src/types/glyphCatalog.ts @@ -72,6 +72,14 @@ export interface GlyphCatalogFrame { export type GridReadiness = "Initial" | "Stale" | "Visible" | "Complete" | "Unavailable"; +/** One fixed root page selected for an atomic Grid replacement. */ +export interface GlyphCatalogAtlasPage { + readonly glyphIds: GlyphId[]; + readonly pageIndex: number; + readonly pageCount: number; + readonly replacementPageIndices: number[]; +} + /** Mutable catalog inputs requested by React for the latest authored revision. */ export interface GlyphCatalogControllerFrame { readonly glyphs: readonly GlyphCatalogItem[]; diff --git a/apps/desktop/src/shared/workspace/PortByteStream.test.ts b/apps/desktop/src/shared/workspace/PortByteStream.test.ts index 145e9eb3..a0519a88 100644 --- a/apps/desktop/src/shared/workspace/PortByteStream.test.ts +++ b/apps/desktop/src/shared/workspace/PortByteStream.test.ts @@ -36,6 +36,22 @@ describe("bounded byte delivery over a message port", () => { receiver.close(); }); + it("re-chunks a source that exceeds the transport maximum", async () => { + const lane = new MessageChannel(); + const sender = new PortByteStream(nodePortTransport(lane.port1)); + const receiver = new PortByteStream(nodePortTransport(lane.port2)); + const writes: number[][] = []; + + await Promise.all([ + sender.send(stream([1, 2, 3, 4, 5]), undefined, 2), + receiver.receive((_offset, bytes) => writes.push([...bytes])), + ]); + + expect(writes).toEqual([[1, 2], [3, 4], [5]]); + sender.close(); + receiver.close(); + }); + it("cancels the source when the receiving sink rejects a chunk", async () => { const lane = new MessageChannel(); const sender = new PortByteStream(nodePortTransport(lane.port1)); diff --git a/apps/desktop/src/shared/workspace/PortByteStream.ts b/apps/desktop/src/shared/workspace/PortByteStream.ts index 6ed714a2..478d631b 100644 --- a/apps/desktop/src/shared/workspace/PortByteStream.ts +++ b/apps/desktop/src/shared/workspace/PortByteStream.ts @@ -18,8 +18,19 @@ export class PortByteStream { transport.onClose(() => this.#close(new Error("byte stream port closed"), false)); } - /** Sends a native/web byte stream and waits for each receiver acknowledgment. */ - async send(source: ByteReadableStream): Promise { + /** Sends a native/web byte stream and waits for each sink and receiver acknowledgment. */ + async send( + source: ByteReadableStream, + observe?: (bytes: Uint8Array) => void | Promise, + maximumLength?: number, + ): Promise { + if ( + maximumLength !== undefined && + (!Number.isSafeInteger(maximumLength) || maximumLength < 1) + ) { + throw new Error("byte stream maximum length must be a positive safe integer"); + } + const reader = source.getReader(); let totalLength = 0; @@ -33,19 +44,24 @@ export class PortByteStream { value.byteOffset, value.byteLength, ); - const nextOffset = totalLength + bytes.byteLength; - const controlPromise = this.#next(); - this.#transport.post({ - kind: "chunk", - offset: totalLength, - bytes, - } satisfies ByteStreamMessage); - const control = readControl(await controlPromise); - if (control.kind === "cancel") throw new Error(control.message); - if (control.nextOffset !== nextOffset) { - throw new Error(`byte stream expected acknowledgment ${nextOffset}`); + await observe?.(bytes); + const sendLength = maximumLength ?? Math.max(1, bytes.byteLength); + for (let start = 0; start < bytes.byteLength; start += sendLength) { + const chunk = bytes.subarray(start, start + sendLength); + const nextOffset = totalLength + chunk.byteLength; + const controlPromise = this.#next(); + this.#transport.post({ + kind: "chunk", + offset: totalLength, + bytes: chunk, + } satisfies ByteStreamMessage); + const control = readControl(await controlPromise); + if (control.kind === "cancel") throw new Error(control.message); + if (control.nextOffset !== nextOffset) { + throw new Error(`byte stream expected acknowledgment ${nextOffset}`); + } + totalLength = nextOffset; } - totalLength = nextOffset; } this.#transport.post({ kind: "complete", totalLength } satisfies ByteStreamMessage); diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index c56e1e25..aa51183a 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -51,6 +51,23 @@ export type WorkspaceGlyphSnapshot = { layers: WorkspaceGlyphLayerSnapshot[]; }; +/** Process-local origin required to stream or discard one prepared atlas page. */ +export type SlugAtlasOrigin = "native" | "cached"; + +/** Prepared page descriptor paired with its utility-owned byte origin. */ +export type WorkspaceSlugAtlas = SlugAtlas & { + origin: SlugAtlasOrigin; +}; + +/** One deterministic fixed-page request within the current authored revision. */ +export type WorkspaceSlugAtlasPageRequest = { + glyphIds: GlyphId[]; + alignment: number; + pageIndex: number; + pageCount: number; + replacementPageIndices: number[]; +}; + export type WorkspaceDocumentSourceKind = "untitled" | "package" | "imported"; /** Bounded byte delivery over a dedicated transferred port. */ @@ -207,10 +224,10 @@ export type SyncCallMap = { request: { alignment: number }; response: SlugAtlas; }; - /** Builds one ordered root-glyph page and its component closure. */ + /** Opens or builds one fixed root-glyph page and its component closure. */ "workspace.slugAtlasPagePrepare": { - request: { glyphIds: GlyphId[]; alignment: number }; - response: SlugAtlas; + request: WorkspaceSlugAtlasPageRequest; + response: WorkspaceSlugAtlas; }; /** Streams bounded atlas chunks over the transferred response port. */ "workspace.slugAtlasStream": { @@ -219,7 +236,7 @@ export type SyncCallMap = { }; /** Streams one prepared page over the transferred response port. */ "workspace.slugAtlasPageStream": { - request: { generation: number; maximumLength: number }; + request: { generation: number; origin: SlugAtlasOrigin; maximumLength: number }; response: null; }; /** Releases native CPU residency when adapter initialization is rejected. */ @@ -229,7 +246,7 @@ export type SyncCallMap = { }; /** Releases one rejected prepared page. */ "workspace.slugAtlasPageDiscard": { - request: { generation: number }; + request: { generation: number; origin: SlugAtlasOrigin }; response: null; }; /** Evaluates font-owned independent and cross-axis mappings in Rust. */ diff --git a/apps/desktop/src/utility/workspace.ts b/apps/desktop/src/utility/workspace.ts index ab4cb57c..5d219659 100644 --- a/apps/desktop/src/utility/workspace.ts +++ b/apps/desktop/src/utility/workspace.ts @@ -3,12 +3,14 @@ import { electronPortTransport, parentPortTransport } from "../shared/workspace/ import { WorkspaceHost } from "./workspace/WorkspaceHost"; const documentsRoot = process.argv[2]; -if (!documentsRoot) { - throw new Error("workspace utility process requires a documents root argument"); +const atlasCacheRoot = process.argv[3]; +if (!documentsRoot || !atlasCacheRoot) { + throw new Error("workspace utility process requires document and atlas cache root arguments"); } new WorkspaceHost({ documentsRoot, + atlasCacheRoot, shell: parentPortTransport(), portTransport: (port) => electronPortTransport(port as MessagePortMain), }).start(); diff --git a/apps/desktop/src/utility/workspace/CachedAtlas.test.ts b/apps/desktop/src/utility/workspace/CachedAtlas.test.ts new file mode 100644 index 00000000..1bbe6509 --- /dev/null +++ b/apps/desktop/src/utility/workspace/CachedAtlas.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { mintAxisId, mintGlyphId, mintSourceId, type GlyphId, type SlugAtlas } from "@shift/types"; +import { + openCachedAtlas, + pruneCachedAtlases, + publishCachedAtlas, + stageCachedAtlasPage, +} from "./CachedAtlas"; +import type { CachedAtlasKey, CachedAtlasPageRequest, StagedCachedAtlasPage } from "./types"; + +const glyphA = mintGlyphId(); +const glyphB = mintGlyphId(); +const source = mintSourceId(); +const axis = mintAxisId(); + +describe("CachedAtlas keeps only validated latest document pages", () => { + let rootPath: string; + + beforeEach(() => { + rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "shift-cached-atlas-")); + }); + + afterEach(() => { + fs.rmSync(rootPath, { recursive: true, force: true }); + }); + + it("round trips independently compressed fixed pages", async () => { + const key = cacheKey("document-a", "revision-1"); + const first = await stagePage(key, 0, 2, [glyphA], Uint8Array.of(1, 2, 3)); + const second = await stagePage(key, 1, 2, [glyphB], Uint8Array.of(4, 5)); + await publish(key, [first, second], [0, 1]); + + const opened = await openCachedAtlas(rootPath, pageRequest(key, 1, [glyphB], [0, 1])); + + expect(opened?.atlas.glyphs.map((glyph) => glyph.glyphId)).toEqual([glyphB]); + expect(opened?.atlas.weightSets[0]?.basis.coefficients[0]).toBeInstanceOf(Float64Array); + expect(await readOpened(opened)).toEqual(Uint8Array.of(4, 5)); + }); + + it("falls back to a miss when a compressed page is corrupt", async () => { + const key = cacheKey("document-a", "revision-1"); + const staged = await stagePage(key, 0, 1, [glyphA], Uint8Array.of(1, 2, 3)); + await publish(key, [staged], [0]); + const filePath = publishedFiles()[0]!; + const bytes = fs.readFileSync(filePath); + bytes[bytes.length - 1] ^= 0xff; + fs.writeFileSync(filePath, bytes); + + const opened = await openCachedAtlas(rootPath, pageRequest(key, 0, [glyphA], [0])); + + expect(opened).toBeNull(); + expect(publishedFiles()).toEqual([]); + }); + + it("retains the most recently used entry under the global byte budget", async () => { + const firstKey = cacheKey("document-a", "revision-1"); + const secondKey = cacheKey("document-b", "revision-1"); + const first = await stagePage(firstKey, 0, 1, [glyphA], Uint8Array.of(1, 2, 3)); + await publish(firstKey, [first], [0]); + const second = await stagePage(secondKey, 0, 1, [glyphB], Uint8Array.of(4, 5, 6)); + const budget = await publish(secondKey, [second], [0]); + await readOpened(await openCachedAtlas(rootPath, pageRequest(firstKey, 0, [glyphA], [0]))); + + await pruneCachedAtlases(rootPath, budget); + + expect(await openCachedAtlas(rootPath, pageRequest(secondKey, 0, [glyphB], [0]))).toBeNull(); + expect( + await readOpened(await openCachedAtlas(rootPath, pageRequest(firstKey, 0, [glyphA], [0]))), + ).toEqual(Uint8Array.of(1, 2, 3)); + }); + + it("publishes a new revision only after every replacement page is ready", async () => { + const oldKey = cacheKey("document-a", "revision-1"); + const oldPages = await stageBoth(oldKey, Uint8Array.of(1), Uint8Array.of(2)); + await publish(oldKey, oldPages, [0, 1]); + const newKey = cacheKey("document-a", "revision-2"); + const first = await stagePage(newKey, 0, 2, [glyphA], Uint8Array.of(3)); + + const result = await publishAttempt(newKey, [first], [0, 1]); + + expect(result).toBeNull(); + expect( + await readOpened(await openCachedAtlas(rootPath, pageRequest(oldKey, 1, [glyphB], [0, 1]))), + ).toEqual(Uint8Array.of(2)); + }); + + it("carries unchanged pages into the latest document revision", async () => { + const oldKey = cacheKey("document-a", "revision-1"); + await publish(oldKey, await stageBoth(oldKey, Uint8Array.of(1), Uint8Array.of(2)), [0, 1]); + const newKey = cacheKey("document-a", "revision-2"); + const replacement = await stagePage(newKey, 0, 2, [glyphA], Uint8Array.of(3)); + + await publish(newKey, [replacement], [0], 2); + + expect(await openCachedAtlas(rootPath, pageRequest(oldKey, 0, [glyphA], [0]))).toBeNull(); + expect(publishedFiles()).toHaveLength(1); + expect( + await readOpened(await openCachedAtlas(rootPath, pageRequest(newKey, 1, [glyphB], [0]))), + ).toEqual(Uint8Array.of(2)); + }); + + async function stagePage( + key: CachedAtlasKey, + pageIndex: number, + totalPages: number, + glyphIds: GlyphId[], + bytes: Uint8Array, + ): Promise { + const request = pageRequest(key, pageIndex, glyphIds, [0, 1].slice(0, totalPages)); + const sink = stageCachedAtlasPage(rootPath, request, descriptor(glyphIds, bytes.length)); + await sink.write(bytes); + return sink.complete(); + } + + async function stageBoth( + key: CachedAtlasKey, + first: Uint8Array, + second: Uint8Array, + ): Promise { + return Promise.all([ + stagePage(key, 0, 2, [glyphA], first), + stagePage(key, 1, 2, [glyphB], second), + ]); + } + + async function publish( + key: CachedAtlasKey, + pages: StagedCachedAtlasPage[], + replacementPageIndices: number[], + totalPages?: number, + ): Promise { + const published = await publishAttempt(key, pages, replacementPageIndices, totalPages); + if (published === null) throw new Error("expected CachedAtlas publication"); + return published; + } + + function publishAttempt( + key: CachedAtlasKey, + pages: StagedCachedAtlasPage[], + replacementPageIndices: number[], + totalPages?: number, + ): Promise { + return publishCachedAtlas(rootPath, { + key, + alignment: 256, + pageCount: + totalPages ?? + (pages.some((page) => page.pageIndex === 1) || replacementPageIndices.length > 1 ? 2 : 1), + replacementPageIndices, + stagedPages: new Map(pages.map((page) => [page.pageIndex, page])), + }); + } + + function publishedFiles(): string[] { + return fs + .readdirSync(rootPath) + .filter((name) => name.endsWith(".atlas")) + .map((name) => path.join(rootPath, name)); + } +}); + +function cacheKey(documentKey: string, revisionKey: string): CachedAtlasKey { + return { documentKey, revisionKey }; +} + +function pageRequest( + key: CachedAtlasKey, + pageIndex: number, + glyphIds: GlyphId[], + replacementPageIndices: number[], +): CachedAtlasPageRequest { + return { + key, + alignment: 256, + pageIndex, + pageCount: replacementPageIndices.length > 1 || pageIndex === 1 ? 2 : 1, + glyphIds, + replacementPageIndices, + }; +} + +function descriptor(glyphIds: GlyphId[], totalLength: number): SlugAtlas { + const empty = { offset: 0, length: 0 }; + return { + generation: 1, + bandCount: 16, + weightCount: 1, + layout: { + baseCurves: empty, + curveDeltas: empty, + sparseDeltas: empty, + glyphs: empty, + sources: empty, + sourceAdvances: empty, + componentGlyphs: empty, + componentParts: empty, + components: empty, + componentSources: empty, + anchorSources: empty, + lineBits: empty, + totalLength, + }, + previewExtents: { horizontal: 0, minimumY: 0, maximumY: 0 }, + glyphs: glyphIds.map((glyphId) => ({ glyphId, defaultGlyph: 0, exactSources: [] })), + weightSets: [ + { + basis: { + sourceIds: [source], + regions: [[{ axisId: axis, lower: -1, peak: 0, upper: 1 }]], + coefficients: [new Float64Array([1])], + }, + sourceWeightIndices: [0], + }, + ], + atlasGlyphCount: glyphIds.length, + curveCount: 0, + componentCount: 0, + }; +} + +async function readOpened( + opened: Awaited>, +): Promise { + if (!opened) return null; + + const reader = opened.stream.getReader(); + const chunks: Uint8Array[] = []; + try { + for (;;) { + const result = await reader.read(); + if (result.done) break; + chunks.push(result.value); + } + } finally { + reader.releaseLock(); + } + + const totalLength = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const bytes = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} diff --git a/apps/desktop/src/utility/workspace/CachedAtlas.ts b/apps/desktop/src/utility/workspace/CachedAtlas.ts new file mode 100644 index 00000000..f5a92960 --- /dev/null +++ b/apps/desktop/src/utility/workspace/CachedAtlas.ts @@ -0,0 +1,710 @@ +import crypto from "node:crypto"; +import { once } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createZstdCompress, createZstdDecompress } from "node:zlib"; +import type { + GlyphId, + InterpolationBasis, + SlugAtlas, + SlugGlyph, + SlugWeightSet, +} from "@shift/types"; +import { z } from "zod"; +import type { + CachedAtlas, + CachedAtlasFile, + CachedAtlasKey, + CachedAtlasPage, + CachedAtlasPageRequest, + CachedAtlasPageSink, + CachedAtlasPublication, + CachedSlugAtlas, + OpenedCachedAtlasPage, + StagedCachedAtlasPage, +} from "./types"; + +export const DEFAULT_ATLAS_CACHE_BYTE_BUDGET = 1024 * 1024 * 1024; + +const FORMAT = "shift.slug-atlas-cache.v1" as const; +const MAGIC = Buffer.from("SHATLAS1"); +const INDEX_CHECKSUM_BYTES = 32; +const INDEX_CHECKSUM_OFFSET = MAGIC.byteLength + 4; +const HEADER_BYTES = INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES; +const MAXIMUM_INDEX_BYTES = 64 * 1024 * 1024; +const STAGING_SESSION = `${process.pid}-${crypto.randomUUID()}`; +let lastTouchMilliseconds = 0; + +const nonnegativeInteger = z.number().int().nonnegative().safe(); +const finiteNumber = z.number().finite(); +const sectionSchema = z + .object({ + offset: nonnegativeInteger, + length: nonnegativeInteger, + }) + .strict(); +const interpolationSupportSchema = z + .object({ + axisId: z.string(), + lower: finiteNumber, + peak: finiteNumber, + upper: finiteNumber, + }) + .strict(); +const interpolationBasisSchema = z + .object({ + sourceIds: z.array(z.string()), + regions: z.array(z.array(interpolationSupportSchema)), + coefficients: z.array(z.array(finiteNumber)), + }) + .strict(); +const slugAtlasSchema = z + .object({ + bandCount: nonnegativeInteger, + weightCount: nonnegativeInteger, + layout: z + .object({ + baseCurves: sectionSchema, + curveDeltas: sectionSchema, + sparseDeltas: sectionSchema, + glyphs: sectionSchema, + sources: sectionSchema, + sourceAdvances: sectionSchema, + componentGlyphs: sectionSchema, + componentParts: sectionSchema, + components: sectionSchema, + componentSources: sectionSchema, + anchorSources: sectionSchema, + lineBits: sectionSchema, + totalLength: nonnegativeInteger, + }) + .strict(), + previewExtents: z + .object({ + horizontal: finiteNumber, + minimumY: finiteNumber, + maximumY: finiteNumber, + }) + .strict(), + glyphs: z.array( + z + .object({ + glyphId: z.string(), + defaultGlyph: nonnegativeInteger, + exactSources: z.array( + z + .object({ + sourceId: z.string(), + glyphIndex: nonnegativeInteger, + }) + .strict(), + ), + }) + .strict(), + ), + weightSets: z.array( + z + .object({ + basis: interpolationBasisSchema, + sourceWeightIndices: z.array(nonnegativeInteger), + }) + .strict(), + ), + atlasGlyphCount: nonnegativeInteger, + curveCount: nonnegativeInteger, + componentCount: nonnegativeInteger, + }) + .strict(); +const cachedAtlasPageSchema = z + .object({ + pageIndex: nonnegativeInteger, + glyphIds: z.array(z.string()), + atlas: slugAtlasSchema, + compressedOffset: nonnegativeInteger, + compressedLength: nonnegativeInteger, + decodedLength: nonnegativeInteger, + checksum: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict(); +const cachedAtlasSchema = z + .object({ + format: z.literal(FORMAT), + documentKey: z.string(), + revisionKey: z.string(), + bandCount: nonnegativeInteger, + alignment: nonnegativeInteger, + pageCount: nonnegativeInteger, + pages: z.array(cachedAtlasPageSchema), + }) + .strict(); + +/** Starts independent Zstd compression for one completed native page stream. */ +export function stageCachedAtlasPage( + rootPath: string, + request: CachedAtlasPageRequest, + descriptor: SlugAtlas, +): CachedAtlasPageSink { + validatePageRequest(request); + const filePath = stagedPagePath(rootPath, request.key, request.pageIndex); + const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const compressor = createZstdCompress(); + const output = fs.createWriteStream(temporaryPath, { flags: "wx" }); + const compression = pipeline(compressor, output); + compression.catch(() => {}); + let decodedLength = 0; + let settled = false; + + return { + async write(bytes): Promise { + if (settled) throw new Error("cached atlas page sink is already settled"); + decodedLength += bytes.byteLength; + if (!Number.isSafeInteger(decodedLength)) { + throw new Error("cached atlas decoded length exceeds safe integer range"); + } + if (!compressor.write(bytes)) await once(compressor, "drain"); + }, + + async complete(): Promise { + if (settled) throw new Error("cached atlas page sink is already settled"); + settled = true; + compressor.end(); + + try { + await compression; + if (decodedLength !== descriptor.layout.totalLength) { + throw new Error( + `cached atlas page wrote ${decodedLength} bytes; expected ${descriptor.layout.totalLength}`, + ); + } + + await fs.promises.rename(temporaryPath, filePath); + const compressedLength = (await fs.promises.stat(filePath)).size; + const checksum = await checksumFile(filePath); + const atlas = withoutGeneration(descriptor); + + return { + pageIndex: request.pageIndex, + glyphIds: [...request.glyphIds], + atlas, + filePath, + compressedLength, + decodedLength, + checksum, + }; + } catch (error) { + await removeFiles([temporaryPath, filePath]); + throw error; + } + }, + + async discard(): Promise { + if (!settled) { + settled = true; + compressor.destroy(new Error("cached atlas page staging discarded")); + } + + try { + await compression; + } catch { + // Discard owns this expected stream failure. + } + await removeFiles([temporaryPath, filePath]); + }, + }; +} + +/** Opens and validates one fixed page from the latest matching document entry. */ +export async function openCachedAtlas( + rootPath: string, + request: CachedAtlasPageRequest, +): Promise { + validatePageRequest(request); + const filePath = cachedAtlasPath(rootPath, request.key.documentKey); + + try { + const cached = await readCachedAtlas(filePath); + if ( + cached.documentKey !== request.key.documentKey || + cached.revisionKey !== request.key.revisionKey || + cached.alignment !== request.alignment || + cached.pageCount !== request.pageCount + ) { + return null; + } + + const page = cached.pages.find((candidate) => candidate.pageIndex === request.pageIndex); + if (!page || !sameGlyphIds(page.glyphIds, request.glyphIds)) return null; + + const payloadOffset = await payloadStart(filePath); + const checksum = await checksumFileRange( + filePath, + payloadOffset + page.compressedOffset, + page.compressedLength, + ); + if (checksum !== page.checksum) { + await removeCachedAtlas(filePath); + return null; + } + + const descriptor = withTypedCoefficients(page.atlas); + const fileDescriptor = fs.openSync(filePath, "r"); + const compressed = fs.createReadStream(filePath, { + fd: fileDescriptor, + autoClose: true, + start: payloadOffset + page.compressedOffset, + end: payloadOffset + page.compressedOffset + page.compressedLength - 1, + }); + const decompressed = compressed.pipe(createZstdDecompress()); + await touchCachedAtlas(filePath); + + return { + atlas: descriptor, + stream: Readable.toWeb(decompressed) as OpenedCachedAtlasPage["stream"], + }; + } catch { + await removeCachedAtlas(filePath); + return null; + } +} + +/** Publishes a complete latest entry, carrying forward only declared unchanged pages. */ +export async function publishCachedAtlas( + rootPath: string, + publication: CachedAtlasPublication, +): Promise { + validatePublication(publication); + const targetPath = cachedAtlasPath(rootPath, publication.key.documentKey); + const replacementIndices = new Set(publication.replacementPageIndices); + if ([...replacementIndices].some((pageIndex) => !publication.stagedPages.has(pageIndex))) { + return null; + } + + const previous = await readCarrySource(targetPath, publication, replacementIndices); + if (!previous && publication.stagedPages.size < publication.pageCount) return null; + + const pages: CachedAtlasPage[] = []; + let compressedOffset = 0; + for (let pageIndex = 0; pageIndex < publication.pageCount; pageIndex += 1) { + const staged = publication.stagedPages.get(pageIndex); + const carried = previous?.cached.pages.find((page) => page.pageIndex === pageIndex); + const page = staged + ? stagedPage(staged, compressedOffset) + : carried + ? { ...carried, compressedOffset } + : null; + if (!page) return null; + + pages.push(page); + compressedOffset += page.compressedLength; + } + + const bandCount = pages[0]?.atlas.bandCount; + if (bandCount === undefined || pages.some((page) => page.atlas.bandCount !== bandCount)) { + throw new Error("cached atlas pages disagree about band count"); + } + const cached: CachedAtlas = { + format: FORMAT, + ...publication.key, + bandCount, + alignment: publication.alignment, + pageCount: publication.pageCount, + pages, + }; + const index = Buffer.from(JSON.stringify(cached, typedArrayReplacer)); + if (index.byteLength > MAXIMUM_INDEX_BYTES) { + throw new Error("cached atlas index exceeds the supported size"); + } + + const temporaryPath = `${targetPath}.${crypto.randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const output = await fs.promises.open(temporaryPath, "wx"); + + try { + const header = Buffer.alloc(HEADER_BYTES); + MAGIC.copy(header); + header.writeUInt32LE(index.byteLength, MAGIC.byteLength); + crypto.createHash("sha256").update(index).digest().copy(header, INDEX_CHECKSUM_OFFSET); + await output.writeFile(header); + await output.writeFile(index); + + for (const page of pages) { + const staged = publication.stagedPages.get(page.pageIndex); + if (staged) { + await copyFileInto(staged.filePath, output); + continue; + } + + if (!previous) throw new Error("cached atlas carry source disappeared"); + const carried = previous.cached.pages[page.pageIndex]; + if (!carried) throw new Error("cached atlas carry page disappeared"); + await copyRangeInto( + targetPath, + previous.payloadOffset + carried.compressedOffset, + carried.compressedLength, + output, + ); + } + + await output.sync(); + await output.close(); + await fs.promises.rename(temporaryPath, targetPath); + await touchCachedAtlas(targetPath); + await removeFiles([...publication.stagedPages.values()].map((page) => page.filePath)); + return (await fs.promises.stat(targetPath)).size; + } catch (error) { + await output.close().catch(() => {}); + await fs.promises.rm(temporaryPath, { force: true }); + throw error; + } +} + +/** Removes least-recently-used published entries until the global byte budget is met. */ +export async function pruneCachedAtlases(rootPath: string, byteBudget: number): Promise { + if (!Number.isSafeInteger(byteBudget) || byteBudget < 0) { + throw new Error("cached atlas byte budget must be a non-negative safe integer"); + } + if (!fs.existsSync(rootPath)) return; + await removeAbandonedStaging(rootPath); + + const entries: CachedAtlasFile[] = []; + for (const name of await fs.promises.readdir(rootPath)) { + if (!name.endsWith(".atlas")) continue; + + const filePath = path.join(rootPath, name); + try { + const stat = await fs.promises.stat(filePath); + if (stat.isFile()) entries.push({ filePath, name, bytes: stat.size, touched: stat.mtimeMs }); + } catch { + // A concurrent utility may have replaced or removed this disposable entry. + } + } + + let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); + entries.sort( + (left, right) => left.touched - right.touched || left.name.localeCompare(right.name), + ); + for (const entry of entries) { + if (totalBytes <= byteBudget) break; + + try { + await fs.promises.rm(entry.filePath, { force: true }); + totalBytes -= entry.bytes; + } catch { + // Another utility owns the same global budget and may already have removed it. + } + } +} + +function validatePageRequest(request: CachedAtlasPageRequest): void { + if ( + !Number.isSafeInteger(request.alignment) || + request.alignment < 1 || + !Number.isSafeInteger(request.pageIndex) || + request.pageIndex < 0 || + !Number.isSafeInteger(request.pageCount) || + request.pageCount < 1 || + request.pageIndex >= request.pageCount + ) { + throw new Error("invalid cached atlas page request"); + } + validatePageIndices(request.replacementPageIndices, request.pageCount); +} + +function validatePublication(publication: CachedAtlasPublication): void { + if ( + !Number.isSafeInteger(publication.alignment) || + publication.alignment < 1 || + !Number.isSafeInteger(publication.pageCount) || + publication.pageCount < 1 + ) { + throw new Error("invalid cached atlas publication"); + } + validatePageIndices(publication.replacementPageIndices, publication.pageCount); +} + +function validatePageIndices(pageIndices: readonly number[], pageCount: number): void { + const unique = new Set(pageIndices); + if ( + unique.size !== pageIndices.length || + pageIndices.some( + (pageIndex) => !Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount, + ) + ) { + throw new Error("invalid cached atlas replacement pages"); + } +} + +function withoutGeneration(descriptor: SlugAtlas): CachedSlugAtlas { + const { generation: _generation, ...atlas } = descriptor; + return atlas; +} + +function withTypedCoefficients(atlas: CachedSlugAtlas): CachedSlugAtlas { + return { + ...atlas, + glyphs: atlas.glyphs as SlugGlyph[], + weightSets: atlas.weightSets.map( + (set): SlugWeightSet => ({ + ...set, + basis: { + ...set.basis, + coefficients: set.basis.coefficients.map( + (coefficients) => new Float64Array(coefficients), + ), + } as InterpolationBasis, + }), + ), + }; +} + +function typedArrayReplacer(_key: string, value: unknown): unknown { + return value instanceof Float64Array ? [...value] : value; +} + +async function readCachedAtlas(filePath: string): Promise { + const file = await fs.promises.open(filePath, "r"); + try { + const header = Buffer.alloc(HEADER_BYTES); + const headerRead = await file.read(header, 0, header.byteLength, 0); + if ( + headerRead.bytesRead !== header.byteLength || + !header.subarray(0, MAGIC.byteLength).equals(MAGIC) + ) { + throw new Error("invalid cached atlas header"); + } + + const indexLength = header.readUInt32LE(MAGIC.byteLength); + if (indexLength < 2 || indexLength > MAXIMUM_INDEX_BYTES) { + throw new Error("invalid cached atlas index length"); + } + const index = Buffer.alloc(indexLength); + const indexRead = await file.read(index, 0, index.byteLength, HEADER_BYTES); + if (indexRead.bytesRead !== index.byteLength) throw new Error("truncated cached atlas index"); + + const expectedIndexChecksum = header.subarray( + INDEX_CHECKSUM_OFFSET, + INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES, + ); + const indexChecksum = crypto.createHash("sha256").update(index).digest(); + if (!crypto.timingSafeEqual(indexChecksum, expectedIndexChecksum)) { + throw new Error("cached atlas index checksum does not match"); + } + + const parsed = cachedAtlasSchema.parse(JSON.parse(index.toString("utf8"))); + const cached = { + ...parsed, + pages: parsed.pages.map((page) => ({ + ...page, + glyphIds: page.glyphIds as GlyphId[], + atlas: withTypedCoefficients(page.atlas as unknown as CachedSlugAtlas), + })), + } satisfies CachedAtlas; + await validateCachedAtlas(filePath, cached, HEADER_BYTES + indexLength); + return cached; + } finally { + await file.close(); + } +} + +async function validateCachedAtlas( + filePath: string, + cached: CachedAtlas, + payloadOffset: number, +): Promise { + if (cached.pages.length !== cached.pageCount) { + throw new Error("cached atlas page count does not match its index"); + } + + let compressedOffset = 0; + for (let pageIndex = 0; pageIndex < cached.pageCount; pageIndex += 1) { + const page = cached.pages[pageIndex]; + if ( + !page || + page.pageIndex !== pageIndex || + page.compressedOffset !== compressedOffset || + page.decodedLength !== page.atlas.layout.totalLength || + page.atlas.bandCount !== cached.bandCount + ) { + throw new Error("cached atlas page index is inconsistent"); + } + compressedOffset += page.compressedLength; + } + + const stat = await fs.promises.stat(filePath); + if (payloadOffset + compressedOffset !== stat.size) { + throw new Error("cached atlas payload length does not match its index"); + } +} + +async function readCarrySource( + targetPath: string, + publication: CachedAtlasPublication, + replacementIndices: ReadonlySet, +) { + try { + const cached = await readCachedAtlas(targetPath); + const stagedBandCount = publication.stagedPages.values().next().value?.atlas.bandCount; + if ( + cached.documentKey !== publication.key.documentKey || + cached.alignment !== publication.alignment || + cached.pageCount !== publication.pageCount || + (stagedBandCount !== undefined && cached.bandCount !== stagedBandCount) + ) { + return null; + } + + const start = await payloadStart(targetPath); + for (const page of cached.pages) { + if (replacementIndices.has(page.pageIndex)) continue; + + const checksum = await checksumFileRange( + targetPath, + start + page.compressedOffset, + page.compressedLength, + ); + if (checksum !== page.checksum) return null; + } + return { cached, payloadOffset: start }; + } catch { + return null; + } +} + +function stagedPage(page: StagedCachedAtlasPage, compressedOffset: number): CachedAtlasPage { + return { + pageIndex: page.pageIndex, + glyphIds: [...page.glyphIds], + atlas: page.atlas, + compressedOffset, + compressedLength: page.compressedLength, + decodedLength: page.decodedLength, + checksum: page.checksum, + }; +} + +async function payloadStart(filePath: string): Promise { + const file = await fs.promises.open(filePath, "r"); + try { + const header = Buffer.alloc(HEADER_BYTES); + const result = await file.read(header, 0, header.byteLength, 0); + if (result.bytesRead !== header.byteLength) throw new Error("truncated cached atlas header"); + return HEADER_BYTES + header.readUInt32LE(MAGIC.byteLength); + } finally { + await file.close(); + } +} + +async function copyFileInto(sourcePath: string, output: fs.promises.FileHandle): Promise { + for await (const chunk of fs.createReadStream(sourcePath)) { + await output.writeFile(chunk); + } +} + +async function copyRangeInto( + sourcePath: string, + offset: number, + length: number, + output: fs.promises.FileHandle, +): Promise { + if (length === 0) return; + for await (const chunk of fs.createReadStream(sourcePath, { + start: offset, + end: offset + length - 1, + })) { + await output.writeFile(chunk); + } +} + +async function checksumFile(filePath: string): Promise { + const stat = await fs.promises.stat(filePath); + return checksumFileRange(filePath, 0, stat.size); +} + +async function checksumFileRange( + filePath: string, + offset: number, + length: number, +): Promise { + const hash = crypto.createHash("sha256"); + if (length === 0) return hash.digest("hex"); + + for await (const chunk of fs.createReadStream(filePath, { + start: offset, + end: offset + length - 1, + })) { + hash.update(chunk); + } + return hash.digest("hex"); +} + +async function removeAbandonedStaging(rootPath: string): Promise { + const stagingRoot = path.join(rootPath, "staging"); + if (!fs.existsSync(stagingRoot)) return; + + for (const name of await fs.promises.readdir(stagingRoot)) { + if (name === STAGING_SESSION) continue; + + const processId = Number.parseInt(name.split("-", 1)[0] ?? "", 10); + if (Number.isSafeInteger(processId) && processId > 0 && processIsRunning(processId)) continue; + await fs.promises.rm(path.join(stagingRoot, name), { recursive: true, force: true }); + } +} + +function processIsRunning(processId: number): boolean { + try { + process.kill(processId, 0); + return true; + } catch (error) { + return isNodeError(error) && error.code === "EPERM"; + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function touchCachedAtlas(filePath: string): Promise { + const milliseconds = Math.max(Date.now(), lastTouchMilliseconds + 1); + lastTouchMilliseconds = milliseconds; + const touched = new Date(milliseconds); + await fs.promises.utimes(filePath, touched, touched); +} + +function cachedAtlasPath(rootPath: string, documentKey: string): string { + return path.join(rootPath, `${hashKey(documentKey)}.atlas`); +} + +function stagedPagePath(rootPath: string, key: CachedAtlasKey, pageIndex: number): string { + return path.join( + rootPath, + "staging", + STAGING_SESSION, + hashKey(key.documentKey), + hashKey(key.revisionKey), + `${pageIndex}.zst`, + ); +} + +function hashKey(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function sameGlyphIds(left: readonly GlyphId[], right: readonly GlyphId[]): boolean { + return left.length === right.length && left.every((glyphId, index) => glyphId === right[index]); +} + +async function removeCachedAtlas(filePath: string): Promise { + try { + await fs.promises.rm(filePath, { force: true }); + } catch { + // Cache cleanup must never turn a disposable miss into a product failure. + } +} + +async function removeFiles(filePaths: readonly string[]): Promise { + await Promise.all(filePaths.map((filePath) => fs.promises.rm(filePath, { force: true }))); +} diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index 11e42543..49646fd5 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -23,6 +23,7 @@ import type { ByteStreamMessage, ShellCallMap, ShellEventMap, + SlugAtlasOrigin, SyncCallMap, SyncEventMap, WorkspaceDocumentState, @@ -80,6 +81,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { function startHost(shellTransport: Transport): void { new WorkspaceHost({ documentsRoot: tmpRoot, + atlasCacheRoot: path.join(tmpRoot, "atlas-cache"), shell: shellTransport, portTransport: (port) => nodePortTransport(port as NodeMessagePort), }).start(); @@ -117,7 +119,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { sync: SyncChannel, generation: number, maximumLength: number, - page = false, + pageOrigin: SlugAtlasOrigin | null = null, ): Promise { const lane = new MessageChannel(); const chunks: Uint8Array[] = []; @@ -162,8 +164,12 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { }; }); lane.port2.start(); - if (page) { - await sync.call("workspace.slugAtlasPageStream", { generation, maximumLength }, [lane.port1]); + if (pageOrigin) { + await sync.call( + "workspace.slugAtlasPageStream", + { generation, origin: pageOrigin, maximumLength }, + [lane.port1], + ); } else { await sync.call("workspace.slugAtlasStream", { generation, maximumLength }, [lane.port1]); } @@ -250,7 +256,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { expect(atlas.glyphs.map((entry) => entry.glyphId)).toEqual([glyph.glyphId]); }); - it("streams only requested roots in one authored Slug page", async () => { + it("streams requested roots and reuses the completed cached page", async () => { const sync = await connectSyncLane(); const snapshot = await createWorkspace(sync); const first = createGlyphALayer(snapshot.sources[0]!.id); @@ -264,14 +270,22 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { ], }); - const page = await sync.call("workspace.slugAtlasPagePrepare", { + const request = { glyphIds: [secondGlyphId], alignment: 256, - }); - const bytes = await streamSlugAtlas(sync, page.generation, 64, true); - - expect(bytes.byteLength).toBe(page.layout.totalLength); - expect(page.glyphs.map((entry) => entry.glyphId)).toEqual([secondGlyphId]); + pageIndex: 0, + pageCount: 1, + replacementPageIndices: [0], + }; + const page = await sync.call("workspace.slugAtlasPagePrepare", request); + const bytes = await streamSlugAtlas(sync, page.generation, 64, page.origin); + const cached = await sync.call("workspace.slugAtlasPagePrepare", request); + const cachedBytes = await streamSlugAtlas(sync, cached.generation, 64, cached.origin); + + expect(page.origin).toBe("native"); + expect(cached.origin).toBe("cached"); + expect(cachedBytes).toEqual(bytes); + expect(cached.glyphs.map((entry) => entry.glyphId)).toEqual([secondGlyphId]); }); it("cancels native Slug production when the renderer rejects a chunk", async () => { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 4e633f80..0020a584 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -1,9 +1,11 @@ import { createBridge, type ShiftBridge } from "@shift/bridge"; +import fs from "node:fs"; import path from "node:path"; import { serveChannel, type ChannelServer, type Transport } from "../../shared/workspace/channel"; import type { ShellCallMap, ShellEventMap, + SlugAtlasOrigin, SyncCallMap, SyncEventMap, WorkspaceDocumentSourceKind, @@ -11,12 +13,30 @@ import type { WorkspaceExportResult, WorkspaceGlyphSnapshot, WorkspacePackageIdentity, + WorkspaceSlugAtlas, + WorkspaceSlugAtlasPageRequest, WorkspaceSnapshot, } from "../../shared/workspace/protocol"; import { PortByteStream } from "../../shared/workspace/PortByteStream"; +import { + DEFAULT_ATLAS_CACHE_BYTE_BUDGET, + openCachedAtlas, + pruneCachedAtlases, + publishCachedAtlas, + stageCachedAtlasPage, +} from "./CachedAtlas"; import { DocumentStorage } from "./DocumentStorage"; import { PackageOpener } from "./PackageOpener"; -import { PackageAddress, type DocumentAllocation } from "./types"; +import { + PackageAddress, + type CachedAtlasBuild, + type CachedAtlasPageRequest, + type CachedAtlasPageSink, + type DocumentAllocation, + type OpenedCachedAtlasPage, + type PreparedAtlasPage, + type StagedCachedAtlasPage, +} from "./types"; /** * Construction options for {@link WorkspaceHost}. @@ -28,6 +48,8 @@ import { PackageAddress, type DocumentAllocation } from "./types"; */ export type WorkspaceHostOptions = { documentsRoot: string; + atlasCacheRoot: string; + atlasCacheByteBudget?: number; shell: Transport; /** Adapts any transferred workspace port into a transport. */ portTransport: (port: unknown) => Transport; @@ -46,18 +68,26 @@ export class WorkspaceHost { readonly #bridge: ShiftBridge; readonly #documents: DocumentStorage; readonly #packageOpener: PackageOpener; + readonly #atlasCacheRoot: string; + readonly #atlasCacheByteBudget: number; readonly #shellTransport: Transport; readonly #portTransport: (port: unknown) => Transport; #shell: ChannelServer | null = null; #sync: ChannelServer | null = null; #documentId: string | null = null; #packageAddress: PackageAddress | null = null; + #cachedGeneration = 0; + #cachedPages = new Map(); + #preparedPages = new Map(); + #atlasBuilds = new Map(); #operations: Promise = Promise.resolve(); constructor(options: WorkspaceHostOptions) { this.#bridge = createBridge(); this.#documents = new DocumentStorage(options.documentsRoot); this.#packageOpener = new PackageOpener(this.#bridge, this.#documents); + this.#atlasCacheRoot = options.atlasCacheRoot; + this.#atlasCacheByteBudget = options.atlasCacheByteBudget ?? DEFAULT_ATLAS_CACHE_BYTE_BUDGET; this.#shellTransport = options.shell; this.#portTransport = options.portTransport; } @@ -122,27 +152,54 @@ export class WorkspaceHost { this.#serialize(() => this.#bridge.getGlyphPreviews(glyphIds, location)), "workspace.slugAtlasPrepare": ({ alignment }) => this.#serialize(() => this.#bridge.prepareSlugAtlas(alignment)), - "workspace.slugAtlasPagePrepare": ({ glyphIds, alignment }) => - this.#serialize(() => this.#bridge.prepareSlugAtlasPage(glyphIds, alignment)), + "workspace.slugAtlasPagePrepare": (request) => + this.#serialize(() => this.#prepareSlugAtlasPage(request)), "workspace.slugAtlasStream": ({ generation, maximumLength }, context) => this.#serialize(() => this.#streamSlugAtlas(generation, maximumLength, context.ports)), - "workspace.slugAtlasPageStream": ({ generation, maximumLength }, context) => - this.#serialize(() => this.#streamSlugAtlasPage(generation, maximumLength, context.ports)), + "workspace.slugAtlasPageStream": ({ generation, origin, maximumLength }, context) => + this.#serialize(() => + this.#streamSlugAtlasPage(generation, origin, maximumLength, context.ports), + ), "workspace.slugAtlasDiscard": ({ generation }) => this.#serialize(() => { this.#bridge.discardSlugAtlas(generation); return null; }), - "workspace.slugAtlasPageDiscard": ({ generation }) => - this.#serialize(() => { - this.#bridge.discardSlugAtlasPage(generation); - return null; - }), + "workspace.slugAtlasPageDiscard": ({ generation, origin }) => + this.#serialize(() => this.#discardSlugAtlasPage(generation, origin)), "workspace.mapLocation": (location) => this.#serialize(() => this.#bridge.mapLocation(location)), }); } + async #prepareSlugAtlasPage(request: WorkspaceSlugAtlasPageRequest): Promise { + const cacheRequest: CachedAtlasPageRequest = { + ...request, + key: { + documentKey: this.#requireDocumentId(), + revisionKey: this.#bridge.slugAtlasCacheRevision(), + }, + }; + const cached = await openCachedAtlas(this.#atlasCacheRoot, cacheRequest); + if (cached) { + this.#cachedGeneration += 1; + if (!Number.isSafeInteger(this.#cachedGeneration)) { + throw new Error("cached Slug atlas generation overflow"); + } + + const generation = this.#cachedGeneration; + this.#cachedPages.set(generation, cached); + return { ...cached.atlas, generation, origin: "cached" }; + } + + const descriptor = this.#bridge.prepareSlugAtlasPage(request.glyphIds, request.alignment); + this.#preparedPages.set(descriptor.generation, { + request: cacheRequest, + descriptor, + }); + return { ...descriptor, origin: "native" }; + } + async #streamSlugAtlas( generation: number, maximumLength: number, @@ -153,7 +210,11 @@ export class WorkspaceHost { const stream = new PortByteStream(this.#portTransport(port)); try { - await stream.send(this.#bridge.streamSlugAtlas(generation, maximumLength)); + await stream.send( + this.#bridge.streamSlugAtlas(generation, maximumLength), + undefined, + maximumLength, + ); return null; } finally { stream.close(); @@ -162,6 +223,7 @@ export class WorkspaceHost { async #streamSlugAtlasPage( generation: number, + origin: SlugAtlasOrigin, maximumLength: number, ports: readonly unknown[], ): Promise { @@ -170,14 +232,153 @@ export class WorkspaceHost { throw new Error("workspace.slugAtlasPageStream requires a transferred response port"); const stream = new PortByteStream(this.#portTransport(port)); + if (origin === "cached") { + const cached = this.#cachedPages.get(generation); + if (!cached) { + stream.close(); + throw new Error(`unknown cached Slug atlas generation ${generation}`); + } + this.#cachedPages.delete(generation); + + try { + await stream.send(cached.stream, undefined, maximumLength); + try { + await pruneCachedAtlases(this.#atlasCacheRoot, this.#atlasCacheByteBudget); + } catch (error) { + console.error("failed to prune cached Slug atlases", error); + } + return null; + } finally { + stream.close(); + } + } + + const prepared = this.#preparedPages.get(generation); + if (!prepared) { + stream.close(); + throw new Error(`unknown native Slug atlas generation ${generation}`); + } + this.#preparedPages.delete(generation); + let sink: CachedAtlasPageSink | null = null; try { - await stream.send(this.#bridge.streamSlugAtlasPage(generation, maximumLength)); + sink = stageCachedAtlasPage(this.#atlasCacheRoot, prepared.request, prepared.descriptor); + } catch (error) { + console.error("failed to start cached Slug atlas page", error); + } + + try { + await stream.send( + this.#bridge.streamSlugAtlasPage(generation, maximumLength), + async (bytes) => { + if (!sink) return; + + try { + await sink.write(bytes); + } catch (error) { + console.error("failed to stage cached Slug atlas page", error); + const failedSink = sink; + sink = null; + try { + await failedSink.discard(); + } catch (discardError) { + console.error("failed to discard cached Slug atlas page", discardError); + } + } + }, + maximumLength, + ); + + if (sink) { + try { + const staged = await sink.complete(); + sink = null; + await this.#registerCachedAtlasPage(prepared.request, staged); + } catch (error) { + console.error("failed to complete cached Slug atlas page", error); + } + } return null; + } catch (error) { + if (sink) { + try { + await sink.discard(); + } catch (discardError) { + console.error("failed to discard interrupted cached Slug atlas page", discardError); + } + } + throw error; } finally { stream.close(); } } + async #discardSlugAtlasPage(generation: number, origin: SlugAtlasOrigin): Promise { + if (origin === "cached") { + const cached = this.#cachedPages.get(generation); + this.#cachedPages.delete(generation); + if (cached) await cancelCachedAtlasPage(cached); + return null; + } + + this.#preparedPages.delete(generation); + this.#bridge.discardSlugAtlasPage(generation); + return null; + } + + async #registerCachedAtlasPage( + request: CachedAtlasPageRequest, + staged: StagedCachedAtlasPage, + ): Promise { + const buildKey = cachedAtlasBuildKey(request); + let build = this.#atlasBuilds.get(buildKey); + if (!build) { + const replacementPageIndices = new Set(request.replacementPageIndices); + const stagedPages = new Map(); + for (const previousBuild of this.#atlasBuilds.values()) { + const compatible = + previousBuild.key.documentKey === request.key.documentKey && + previousBuild.alignment === request.alignment && + previousBuild.pageCount === request.pageCount; + for (const page of previousBuild.stagedPages.values()) { + if (compatible && !replacementPageIndices.has(page.pageIndex)) { + stagedPages.set(page.pageIndex, page); + } else { + fs.rmSync(page.filePath, { force: true }); + } + } + } + this.#atlasBuilds.clear(); + + build = { + key: request.key, + alignment: request.alignment, + pageCount: request.pageCount, + replacementPageIndices: [...request.replacementPageIndices], + stagedPages, + }; + this.#atlasBuilds.set(buildKey, build); + } + + const previous = build.stagedPages.get(staged.pageIndex); + if (previous) fs.rmSync(previous.filePath, { force: true }); + build.stagedPages.set(staged.pageIndex, staged); + + const publishedBytes = await publishCachedAtlas(this.#atlasCacheRoot, build); + if (publishedBytes === null) return; + + this.#atlasBuilds.delete(buildKey); + await pruneCachedAtlases(this.#atlasCacheRoot, this.#atlasCacheByteBudget); + } + + #discardAtlasBuildsExcept(retainedKey: string | null): void { + for (const [buildKey, build] of this.#atlasBuilds) { + if (buildKey === retainedKey) continue; + + for (const page of build.stagedPages.values()) fs.rmSync(page.filePath, { force: true }); + this.#atlasBuilds.delete(buildKey); + } + } + #create(): WorkspaceDocumentState { const document = this.#documents.createDocument(); @@ -292,6 +493,9 @@ export class WorkspaceHost { const documentId = state.documentId; const address = this.#packageAddress; + this.#cachedPages.clear(); + this.#preparedPages.clear(); + this.#discardAtlasBuildsExcept(null); this.#bridge.closeWorkspace(); this.#documentId = null; this.#packageAddress = null; @@ -351,6 +555,25 @@ export class WorkspaceHost { } } +function cachedAtlasBuildKey(request: CachedAtlasPageRequest): string { + return JSON.stringify([ + request.key.documentKey, + request.key.revisionKey, + request.alignment, + request.pageCount, + request.replacementPageIndices, + ]); +} + +async function cancelCachedAtlasPage(page: OpenedCachedAtlasPage): Promise { + const reader = page.stream.getReader(); + try { + await reader.cancel("cached Slug atlas page discarded"); + } finally { + reader.releaseLock(); + } +} + function parseDocumentSourceKind(sourceKind: string): WorkspaceDocumentSourceKind { if (sourceKind === "untitled" || sourceKind === "package" || sourceKind === "imported") { return sourceKind; diff --git a/apps/desktop/src/utility/workspace/types.ts b/apps/desktop/src/utility/workspace/types.ts index 7b5afb5f..e237308f 100644 --- a/apps/desktop/src/utility/workspace/types.ts +++ b/apps/desktop/src/utility/workspace/types.ts @@ -1,4 +1,96 @@ -import type { WorkspacePackageIdentity } from "../../shared/workspace/protocol"; +import type { GlyphId, SlugAtlas } from "@shift/types"; +import type { ByteReadableStream, WorkspacePackageIdentity } from "../../shared/workspace/protocol"; + +/** Opaque key for one authored revision's disposable Slug pages. */ +export type CachedAtlasKey = { + documentKey: string; + revisionKey: string; +}; + +/** Slug metadata persisted without a process-local prepared generation. */ +export type CachedSlugAtlas = Omit; + +/** One independently compressed fixed root page in a published CachedAtlas. */ +export type CachedAtlasPage = { + pageIndex: number; + glyphIds: GlyphId[]; + atlas: CachedSlugAtlas; + compressedOffset: number; + compressedLength: number; + decodedLength: number; + checksum: string; +}; + +/** One complete, indexed disposable atlas revision. */ +export type CachedAtlas = CachedAtlasKey & { + format: "shift.slug-atlas-cache.v1"; + bandCount: number; + alignment: number; + pageCount: number; + pages: CachedAtlasPage[]; +}; + +/** One native page that has finished compression into staging storage. */ +export type StagedCachedAtlasPage = { + pageIndex: number; + glyphIds: GlyphId[]; + atlas: CachedSlugAtlas; + filePath: string; + compressedLength: number; + decodedLength: number; + checksum: string; +}; + +/** Writable compression boundary for one native page stream. */ +export type CachedAtlasPageSink = { + write(bytes: Uint8Array): Promise; + complete(): Promise; + discard(): Promise; +}; + +/** Fixed-page request metadata shared by visible and background builds. */ +export type CachedAtlasPageRequest = { + key: CachedAtlasKey; + alignment: number; + pageIndex: number; + pageCount: number; + glyphIds: readonly GlyphId[]; + replacementPageIndices: readonly number[]; +}; + +/** Inputs required to publish a complete latest document entry. */ +export type CachedAtlasPublication = { + key: CachedAtlasKey; + alignment: number; + pageCount: number; + replacementPageIndices: readonly number[]; + stagedPages: ReadonlyMap; +}; + +/** In-progress page set for one authored revision. */ +export type CachedAtlasBuild = CachedAtlasPublication & { + stagedPages: Map; +}; + +/** Native prepared page awaiting its renderer stream and cache staging. */ +export type PreparedAtlasPage = { + request: CachedAtlasPageRequest; + descriptor: SlugAtlas; +}; + +/** Published file candidate considered by the global LRU. */ +export type CachedAtlasFile = { + filePath: string; + name: string; + bytes: number; + touched: number; +}; + +/** Validated cached page ready for bounded decompression. */ +export type OpenedCachedAtlasPage = { + atlas: CachedSlugAtlas; + stream: ByteReadableStream; +}; /** Identifies one utility-owned SQLite document allocation. */ export type DocumentAllocation = { diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index 0f2eb942..5bf02976 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -84,6 +84,14 @@ describe("Bridge", () => { return snapshots[0]?.layers[0]?.state; } + it("exposes the durable Slug cache revision", () => { + expect(bridge.slugAtlasCacheRevision()).toBe("0"); + + createGlyphLayer(); + + expect(bridge.slugAtlasCacheRevision()).toBe("1"); + }); + it("creates an untitled workspace with default committed font metadata", () => { expect(bridge.getMetadata()).toMatchObject({ familyName: "Untitled Font", diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index a3310eea..ec2143a8 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -20,7 +20,7 @@ NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bri **Architecture Invariant:** Package inspection methods are read-only and may run without an open workspace. **WHY:** Electron main/utility code must inspect package identity before deciding whether to reuse, hydrate, relink, or orphan a working document. -**Architecture Invariant:** A prepared Slug page remains native until it is consumed once through napi-rs `ReadableStream` chunks. Every font edit invalidates unconsumed output. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. **WHY:** Visible roots take priority, fixed pages yield between native calls, and product upload retains one bounded temporary chunk rather than an atlas-sized JavaScript copy or an unbounded IPC queue. `prepareAuthoredGlyphCompilation()` and the complete endpoint remain diagnostic/profiling boundaries, not product startup scheduling. +**Architecture Invariant:** A prepared Slug page remains native until it is consumed once through napi-rs `ReadableStream` chunks. Every font edit invalidates unconsumed output. The native producer has capacity one, and Electron acknowledges each GPU write before the utility reads another chunk. `slugAtlasCacheRevision()` exposes only the durable authored revision string needed by the utility's disposable cache key; cache bytes and policy remain outside Rust. **WHY:** Visible roots take priority, fixed pages yield between native calls, and product upload retains one bounded temporary chunk rather than an atlas-sized JavaScript copy or an unbounded IPC queue. `prepareAuthoredGlyphCompilation()` and the complete endpoint remain diagnostic/profiling boundaries, not product startup scheduling. ## Codemap @@ -55,6 +55,7 @@ crates/shift-bridge/ - `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, scale-preserving `SlugPreviewExtents`, and aligned resident-section layout. - `authoredGlyphCompilation` -- diagnostic complete location-independent `AuthoredAtlas` prepared before device alignment and consumed by the complete-atlas endpoint. - `SlugAtlasGeneration` -- one aligned native atlas or page consumed by its stream API or released by its discard API. +- `slugAtlasCacheRevision()` -- utility-only durable authored revision key; it does not make cached Slug bytes canonical workspace state. ## How it works @@ -67,7 +68,7 @@ crates/shift-bridge/ 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. 8. `closeWorkspace()` drops the live Rust workspace handle. The utility process retains a clean package-backed SQLite document, but deletes untitled/imported documents and explicitly discarded dirty documents. 9. `exportWorkspace(request)` creates a `FontSaveSnapshot` and exports asynchronously through `shift-backends`. -10. The renderer calls `prepareSlugAtlasPage(glyphIds, alignment)` first for current visible roots, then for deterministic fixed directory pages. Every page independently acquires its indexed component closure and reports shared all-source preview extents. Each bounded build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. New visible work supersedes queued complete-residency pages between calls. The complete preparation endpoints remain available to the external profiler; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. +10. The renderer calls `prepareSlugAtlasPage(glyphIds, alignment)` for deterministic fixed directory pages, prioritizing every page intersecting the current viewport. Every native miss independently acquires its indexed component closure and reports shared all-source preview extents. Each bounded build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. The utility may bypass native preparation with a validated external `CachedAtlas` page keyed by `slugAtlasCacheRevision()`, but cached and native pages share the same bounded renderer stream contract. New visible work supersedes queued complete-residency pages between calls. The complete preparation endpoints remain available to the external profiler; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. ## Type Boundary diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index e31bb574..3016564a 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -84,6 +84,8 @@ export declare class Bridge { * geometry remains native until `stream_slug_atlas` emits bounded chunks. */ prepareSlugAtlas(alignment: number): NapiSlugAtlas + /** Returns the durable authored revision used to address disposable cached atlas pages. */ + slugAtlasCacheRevision(): string /** * Builds one ordered root-glyph page plus its transitive component geometry. * diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 88b82ac0..131eb945 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -968,6 +968,12 @@ impl Bridge { Ok(result) } + /// Returns the durable authored revision used to address disposable cached atlas pages. + #[napi] + pub fn slug_atlas_cache_revision(&self) -> errors::Result { + Ok(self.workspace()?.slug_atlas_cache_revision()?) + } + /// Builds one ordered root-glyph page plus its transitive component geometry. /// /// The page uses the same packed layout as a complete atlas, but excludes diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index 5b97c754..7764dcaa 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -59,7 +59,7 @@ Each glyph owns `band_count` horizontal ranges followed by `band_count` vertical ## Resident variable execution -`build_authored_atlas()` remains the complete-font compiler and profiling boundary. The product Grid uses `build_authored_atlas_page()` for a prioritized visible page followed by deterministic fixed directory pages, so visible replacement, cooperative complete residency, and local edits share one compiler and packed layout. Each compilation creates one `GlyphProjectionSet` for its ordered roots and transitive component closure. Weight collection, root addition, component preparation, exact-source discovery, and fallback resolution all read that same immutable set instead of rebuilding projections or variation models. Fallback and exact-source resolved glyphs are retained only within the current root, bounding temporary memory; the complete set is dropped after atlas construction and never survives authored edits. A patch preserves explicit `GlyphId` mapping and excludes unrelated roots. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. +`build_authored_atlas()` remains the complete-font compiler and profiling boundary. The product Grid uses `build_authored_atlas_page()` for every deterministic fixed directory page intersecting the current viewport before filling the remainder, so atomic multi-page visible replacement, cooperative complete residency, local edits, and disposable per-page caching share one compiler and packed layout. Each compilation creates one `GlyphProjectionSet` for its ordered roots and transitive component closure. Weight collection, root addition, component preparation, exact-source discovery, and fallback resolution all read that same immutable set instead of rebuilding projections or variation models. Fallback and exact-source resolved glyphs are retained only within the current root, bounding temporary memory; the complete set is dropped after atlas construction and never survives authored edits. A patch preserves explicit `GlyphId` mapping and excludes unrelated roots. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. `build_authored_atlas_profiled()` and its page counterpart return nested phase durations from the same compiler path. The bridge uses these functions for `prepareSlugAtlas`; setting `SHIFT_PROFILE_SLUG_ATLAS=1` prints acquisition, projection preparation, weight-set collection, component preparation, fallback bounds, exact-source preparation, atlas addition, layout, and total native time without changing the NAPI endpoint. diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 015ec1cc..b0f9650b 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -11,6 +11,7 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. - **Architecture Invariant:** The workspace is the domain object future bridge or utility-process transports should wrap. +- **Architecture Invariant:** `slug_atlas_cache_revision()` reads the durable authored workspace revision as an opaque string for disposable derived-cache addressing. It does not persist preview bytes or make them authored state. - **Architecture Invariant:** Ledger layer pairs retain the original touched-layer structural classification. Values-only undo/redo restores the target snapshot's canonical numeric values without rebuilding identity indexes or emitting structure; structural replay installs and emits the complete target structure in both directions. - **Architecture Invariant:** Ledger replay restores complete named-instance collections after axis topology so undo/redo never observes an instance against the wrong external-axis shape. - **Architecture Invariant:** Metadata ledger entries store complete pre/post snapshots and replay them independently of font metrics. @@ -54,6 +55,8 @@ crates/shift-workspace/examples/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. +`FontWorkspace::slug_atlas_cache_revision()` returns the persisted authored revision used by the utility process to distinguish disposable `CachedAtlas` entries across edits and process restarts. Save does not alter this key when authored content is unchanged. + `FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition passes the complete request to the store's shared count- and decoded-byte-aware planner. Each directory fact is read once, then reused by bounded payload/component batches of at most 512 layers and 256 MiB decoded bytes. Batches decompress and verify exact lengths plus BLAKE3 in parallel, accumulate the canonical results, and validate the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. ## Profiling diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 94ef690a..dd7f5eaa 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -1111,6 +1111,21 @@ impl FontWorkspace { .is_some_and(|state| state.dirty)) } + /// Returns the durable authored revision used to address disposable derived artifacts. + pub fn slug_atlas_cache_revision(&self) -> Result { + let state = self + .store + .workspace_state()? + .ok_or_else(|| WorkspaceError::CorruptWorkingStore("missing workspace_state".into()))?; + if state.revision < 0 { + return Err(WorkspaceError::CorruptWorkingStore( + "negative workspace revision".into(), + )); + } + + Ok(state.revision.to_string()) + } + pub fn set_document_id(&mut self, document_id: String) -> Result<(), WorkspaceError> { self.store.set_workspace_document_id(document_id)?; Ok(()) diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 509cdc30..4e5919b2 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -578,6 +578,23 @@ fn save_and_save_as_write_the_live_font_to_the_source_package() { assert!(saved.glyph_id_by_name("B").is_some()); } +#[test] +fn slug_atlas_cache_revision_advances_and_survives_resume() { + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("working.sqlite"); + + { + let mut workspace = + FontWorkspace::create_untitled(&store_path, NewWorkspace::new()).unwrap(); + assert_eq!(workspace.slug_atlas_cache_revision().unwrap(), "0"); + create_glyph(&mut workspace, "A", vec![65]); + assert_eq!(workspace.slug_atlas_cache_revision().unwrap(), "1"); + } + + let workspace = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(workspace.slug_atlas_cache_revision().unwrap(), "1"); +} + #[test] fn resume_rebuilds_dirty_untitled_workspace_from_store() { let temp = tempfile::tempdir().unwrap(); diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index a84f5702..0fb47bcc 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -99,6 +99,8 @@ export interface BridgeApi { * geometry remains native until `stream_slug_atlas` emits bounded chunks. */ prepareSlugAtlas(alignment: number): SlugAtlas + /** Returns the durable authored revision used to address disposable cached atlas pages. */ + slugAtlasCacheRevision(): string /** * Builds one ordered root-glyph page plus its transitive component geometry. * From c6f17eccd22a3a4d7a49d5b2768438a5e9107de7 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 16:36:16 +0000 Subject: [PATCH 5/8] Reuse opened atlas cache indexes --- apps/desktop/src/main/docs/DOCS.md | 4 +- .../src/utility/workspace/CachedAtlas.test.ts | 80 ++++++-- .../src/utility/workspace/CachedAtlas.ts | 189 ++++++++++++------ .../utility/workspace/WorkspaceHost.test.ts | 19 +- .../src/utility/workspace/WorkspaceHost.ts | 61 +++++- apps/desktop/src/utility/workspace/types.ts | 9 + 6 files changed, 275 insertions(+), 87 deletions(-) diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index b1d00125..e23db260 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -11,7 +11,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. - **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty documents are pruned. - **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. -- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; stale, corrupt, and evicted entries rebuild. +- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. ## Codemap @@ -63,7 +63,7 @@ On macOS, closing the last window leaves Shift running. A later Dock activation File -> New asks `WorkspaceManager.createUntitled()` for a session. The launcher prepares an idle utility process, so File -> Open overlaps process startup with `showOpenFontDialog()` before asking `WorkspaceManager.openPath(path)`. -For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. Main does not start monolithic Slug preparation: the renderer requests every fixed page intersecting the current viewport first, presents that set atomically, and cooperatively fills remaining pages afterward. The utility serves validated Zstd cache hits through the same bounded stream contract or compiles a native miss and stages it for atomic publication. +For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. Main does not start monolithic Slug preparation: the renderer requests every fixed page intersecting the current viewport first, presents that set atomically, and cooperatively fills remaining pages afterward. The utility opens and validates a matching cache artifact once, serves independently verified Zstd pages through the bounded stream contract, or compiles a native miss and stages it for atomic publication. ### Window Attachment diff --git a/apps/desktop/src/utility/workspace/CachedAtlas.test.ts b/apps/desktop/src/utility/workspace/CachedAtlas.test.ts index 1bbe6509..b40a064a 100644 --- a/apps/desktop/src/utility/workspace/CachedAtlas.test.ts +++ b/apps/desktop/src/utility/workspace/CachedAtlas.test.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import { mintAxisId, mintGlyphId, mintSourceId, type GlyphId, type SlugAtlas } from "@shift/types"; import { + closeCachedAtlas, + loadCachedAtlasPage, openCachedAtlas, pruneCachedAtlases, publishCachedAtlas, @@ -33,11 +35,18 @@ describe("CachedAtlas keeps only validated latest document pages", () => { const second = await stagePage(key, 1, 2, [glyphB], Uint8Array.of(4, 5)); await publish(key, [first, second], [0, 1]); - const opened = await openCachedAtlas(rootPath, pageRequest(key, 1, [glyphB], [0, 1])); + const request = pageRequest(key, 1, [glyphB], [0, 1]); + const opened = await openCachedAtlas(rootPath, request); + if (!opened) throw new Error("expected CachedAtlas to open"); - expect(opened?.atlas.glyphs.map((glyph) => glyph.glyphId)).toEqual([glyphB]); - expect(opened?.atlas.weightSets[0]?.basis.coefficients[0]).toBeInstanceOf(Float64Array); - expect(await readOpened(opened)).toEqual(Uint8Array.of(4, 5)); + try { + const page = await loadCachedAtlasPage(opened, request); + expect(page?.atlas.glyphs.map((glyph) => glyph.glyphId)).toEqual([glyphB]); + expect(page?.atlas.weightSets[0]?.basis.coefficients[0]).toBeInstanceOf(Float64Array); + expect(await readOpened(page)).toEqual(Uint8Array.of(4, 5)); + } finally { + await closeCachedAtlas(opened); + } }); it("falls back to a miss when a compressed page is corrupt", async () => { @@ -49,9 +58,9 @@ describe("CachedAtlas keeps only validated latest document pages", () => { bytes[bytes.length - 1] ^= 0xff; fs.writeFileSync(filePath, bytes); - const opened = await openCachedAtlas(rootPath, pageRequest(key, 0, [glyphA], [0])); + const bytesAfterCorruption = await readPage(pageRequest(key, 0, [glyphA], [0])); - expect(opened).toBeNull(); + expect(bytesAfterCorruption).toBeNull(); expect(publishedFiles()).toEqual([]); }); @@ -62,14 +71,12 @@ describe("CachedAtlas keeps only validated latest document pages", () => { await publish(firstKey, [first], [0]); const second = await stagePage(secondKey, 0, 1, [glyphB], Uint8Array.of(4, 5, 6)); const budget = await publish(secondKey, [second], [0]); - await readOpened(await openCachedAtlas(rootPath, pageRequest(firstKey, 0, [glyphA], [0]))); + await readPage(pageRequest(firstKey, 0, [glyphA], [0])); await pruneCachedAtlases(rootPath, budget); - expect(await openCachedAtlas(rootPath, pageRequest(secondKey, 0, [glyphB], [0]))).toBeNull(); - expect( - await readOpened(await openCachedAtlas(rootPath, pageRequest(firstKey, 0, [glyphA], [0]))), - ).toEqual(Uint8Array.of(1, 2, 3)); + expect(await readPage(pageRequest(secondKey, 0, [glyphB], [0]))).toBeNull(); + expect(await readPage(pageRequest(firstKey, 0, [glyphA], [0]))).toEqual(Uint8Array.of(1, 2, 3)); }); it("publishes a new revision only after every replacement page is ready", async () => { @@ -82,9 +89,7 @@ describe("CachedAtlas keeps only validated latest document pages", () => { const result = await publishAttempt(newKey, [first], [0, 1]); expect(result).toBeNull(); - expect( - await readOpened(await openCachedAtlas(rootPath, pageRequest(oldKey, 1, [glyphB], [0, 1]))), - ).toEqual(Uint8Array.of(2)); + expect(await readPage(pageRequest(oldKey, 1, [glyphB], [0, 1]))).toEqual(Uint8Array.of(2)); }); it("carries unchanged pages into the latest document revision", async () => { @@ -95,13 +100,50 @@ describe("CachedAtlas keeps only validated latest document pages", () => { await publish(newKey, [replacement], [0], 2); - expect(await openCachedAtlas(rootPath, pageRequest(oldKey, 0, [glyphA], [0]))).toBeNull(); + expect(await readPage(pageRequest(oldKey, 0, [glyphA], [0]))).toBeNull(); expect(publishedFiles()).toHaveLength(1); - expect( - await readOpened(await openCachedAtlas(rootPath, pageRequest(newKey, 1, [glyphB], [0]))), - ).toEqual(Uint8Array.of(2)); + expect(await readPage(pageRequest(newKey, 1, [glyphB], [0]))).toEqual(Uint8Array.of(2)); + }); + + it("loads every fixed page from one validated index", async () => { + const key = cacheKey("document-a", "revision-1"); + await publish(key, await stageBoth(key, Uint8Array.of(1), Uint8Array.of(2)), [0, 1]); + const firstRequest = pageRequest(key, 0, [glyphA], [0, 1]); + const opened = await openCachedAtlas(rootPath, firstRequest); + if (!opened) throw new Error("expected CachedAtlas to open"); + corruptPublishedIndex(); + + try { + expect(await readOpened(await loadCachedAtlasPage(opened, firstRequest))).toEqual( + Uint8Array.of(1), + ); + const secondRequest = pageRequest(key, 1, [glyphB], [0, 1]); + expect(await readOpened(await loadCachedAtlasPage(opened, secondRequest))).toEqual( + Uint8Array.of(2), + ); + } finally { + await closeCachedAtlas(opened); + } }); + async function readPage(request: CachedAtlasPageRequest): Promise { + const opened = await openCachedAtlas(rootPath, request); + if (!opened) return null; + + try { + return await readOpened(await loadCachedAtlasPage(opened, request)); + } finally { + await closeCachedAtlas(opened); + } + } + + function corruptPublishedIndex(): void { + const filePath = publishedFiles()[0]!; + const bytes = fs.readFileSync(filePath); + bytes[12] ^= 0xff; + fs.writeFileSync(filePath, bytes); + } + async function stagePage( key: CachedAtlasKey, pageIndex: number, @@ -222,7 +264,7 @@ function descriptor(glyphIds: GlyphId[], totalLength: number): SlugAtlas { } async function readOpened( - opened: Awaited>, + opened: Awaited>, ): Promise { if (!opened) return null; diff --git a/apps/desktop/src/utility/workspace/CachedAtlas.ts b/apps/desktop/src/utility/workspace/CachedAtlas.ts index f5a92960..b63b5dd0 100644 --- a/apps/desktop/src/utility/workspace/CachedAtlas.ts +++ b/apps/desktop/src/utility/workspace/CachedAtlas.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { once } from "node:events"; import fs from "node:fs"; +import type { FileHandle } from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -22,6 +23,7 @@ import type { CachedAtlasPageSink, CachedAtlasPublication, CachedSlugAtlas, + OpenedCachedAtlas, OpenedCachedAtlasPage, StagedCachedAtlasPage, } from "./types"; @@ -35,6 +37,7 @@ const INDEX_CHECKSUM_OFFSET = MAGIC.byteLength + 4; const HEADER_BYTES = INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES; const MAXIMUM_INDEX_BYTES = 64 * 1024 * 1024; const STAGING_SESSION = `${process.pid}-${crypto.randomUUID()}`; +const closedCachedAtlases = new WeakSet(); let lastTouchMilliseconds = 0; const nonnegativeInteger = z.number().int().nonnegative().safe(); @@ -217,60 +220,96 @@ export function stageCachedAtlasPage( }; } -/** Opens and validates one fixed page from the latest matching document entry. */ +/** Opens and validates one latest cache artifact while parsing its index exactly once. */ export async function openCachedAtlas( rootPath: string, request: CachedAtlasPageRequest, -): Promise { +): Promise { validatePageRequest(request); const filePath = cachedAtlasPath(rootPath, request.key.documentKey); + let file: FileHandle | null = null; try { - const cached = await readCachedAtlas(filePath); + file = await fs.promises.open(filePath, "r"); + const cached = await readCachedAtlasFile(file); if ( cached.documentKey !== request.key.documentKey || cached.revisionKey !== request.key.revisionKey || cached.alignment !== request.alignment || cached.pageCount !== request.pageCount ) { + await file.close(); return null; } - const page = cached.pages.find((candidate) => candidate.pageIndex === request.pageIndex); - if (!page || !sameGlyphIds(page.glyphIds, request.glyphIds)) return null; + const payloadOffset = await payloadStartFromFile(file); + await touchCachedAtlas(filePath); + return { atlas: cached, filePath, file, payloadOffset }; + } catch { + await file?.close().catch(() => {}); + await removeCachedAtlas(filePath); + return null; + } +} + +/** Loads one independently compressed page from an already validated cache artifact. */ +export async function loadCachedAtlasPage( + opened: OpenedCachedAtlas, + request: CachedAtlasPageRequest, +): Promise { + validatePageRequest(request); + const cached = opened.atlas; + if ( + cached.documentKey !== request.key.documentKey || + cached.revisionKey !== request.key.revisionKey || + cached.alignment !== request.alignment || + cached.pageCount !== request.pageCount + ) { + return null; + } - const payloadOffset = await payloadStart(filePath); - const checksum = await checksumFileRange( - filePath, - payloadOffset + page.compressedOffset, + const page = cached.pages[request.pageIndex]; + if (!page || !sameGlyphIds(page.glyphIds, request.glyphIds)) return null; + + try { + const compressedOffset = opened.payloadOffset + page.compressedOffset; + const checksum = await checksumFileHandleRange( + opened.filePath, + opened.file, + compressedOffset, page.compressedLength, ); if (checksum !== page.checksum) { - await removeCachedAtlas(filePath); + await closeCachedAtlas(opened).catch(() => {}); + await removeCachedAtlas(opened.filePath); return null; } - const descriptor = withTypedCoefficients(page.atlas); - const fileDescriptor = fs.openSync(filePath, "r"); - const compressed = fs.createReadStream(filePath, { - fd: fileDescriptor, - autoClose: true, - start: payloadOffset + page.compressedOffset, - end: payloadOffset + page.compressedOffset + page.compressedLength - 1, + const compressed = fs.createReadStream(opened.filePath, { + fd: opened.file.fd, + autoClose: false, + start: compressedOffset, + end: compressedOffset + page.compressedLength - 1, }); const decompressed = compressed.pipe(createZstdDecompress()); - await touchCachedAtlas(filePath); - return { - atlas: descriptor, + atlas: page.atlas, stream: Readable.toWeb(decompressed) as OpenedCachedAtlasPage["stream"], }; } catch { - await removeCachedAtlas(filePath); + await closeCachedAtlas(opened).catch(() => {}); + await removeCachedAtlas(opened.filePath); return null; } } +/** Releases the file owned by one opened cache artifact. */ +export async function closeCachedAtlas(opened: OpenedCachedAtlas): Promise { + if (closedCachedAtlases.has(opened)) return; + await opened.file.close(); + closedCachedAtlases.add(opened); +} + /** Publishes a complete latest entry, carrying forward only declared unchanged pages. */ export async function publishCachedAtlas( rootPath: string, @@ -468,50 +507,54 @@ function typedArrayReplacer(_key: string, value: unknown): unknown { async function readCachedAtlas(filePath: string): Promise { const file = await fs.promises.open(filePath, "r"); try { - const header = Buffer.alloc(HEADER_BYTES); - const headerRead = await file.read(header, 0, header.byteLength, 0); - if ( - headerRead.bytesRead !== header.byteLength || - !header.subarray(0, MAGIC.byteLength).equals(MAGIC) - ) { - throw new Error("invalid cached atlas header"); - } + return await readCachedAtlasFile(file); + } finally { + await file.close(); + } +} - const indexLength = header.readUInt32LE(MAGIC.byteLength); - if (indexLength < 2 || indexLength > MAXIMUM_INDEX_BYTES) { - throw new Error("invalid cached atlas index length"); - } - const index = Buffer.alloc(indexLength); - const indexRead = await file.read(index, 0, index.byteLength, HEADER_BYTES); - if (indexRead.bytesRead !== index.byteLength) throw new Error("truncated cached atlas index"); +async function readCachedAtlasFile(file: FileHandle): Promise { + const header = Buffer.alloc(HEADER_BYTES); + const headerRead = await file.read(header, 0, header.byteLength, 0); + if ( + headerRead.bytesRead !== header.byteLength || + !header.subarray(0, MAGIC.byteLength).equals(MAGIC) + ) { + throw new Error("invalid cached atlas header"); + } - const expectedIndexChecksum = header.subarray( - INDEX_CHECKSUM_OFFSET, - INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES, - ); - const indexChecksum = crypto.createHash("sha256").update(index).digest(); - if (!crypto.timingSafeEqual(indexChecksum, expectedIndexChecksum)) { - throw new Error("cached atlas index checksum does not match"); - } + const indexLength = header.readUInt32LE(MAGIC.byteLength); + if (indexLength < 2 || indexLength > MAXIMUM_INDEX_BYTES) { + throw new Error("invalid cached atlas index length"); + } + const index = Buffer.alloc(indexLength); + const indexRead = await file.read(index, 0, index.byteLength, HEADER_BYTES); + if (indexRead.bytesRead !== index.byteLength) throw new Error("truncated cached atlas index"); - const parsed = cachedAtlasSchema.parse(JSON.parse(index.toString("utf8"))); - const cached = { - ...parsed, - pages: parsed.pages.map((page) => ({ - ...page, - glyphIds: page.glyphIds as GlyphId[], - atlas: withTypedCoefficients(page.atlas as unknown as CachedSlugAtlas), - })), - } satisfies CachedAtlas; - await validateCachedAtlas(filePath, cached, HEADER_BYTES + indexLength); - return cached; - } finally { - await file.close(); + const expectedIndexChecksum = header.subarray( + INDEX_CHECKSUM_OFFSET, + INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES, + ); + const indexChecksum = crypto.createHash("sha256").update(index).digest(); + if (!crypto.timingSafeEqual(indexChecksum, expectedIndexChecksum)) { + throw new Error("cached atlas index checksum does not match"); } + + const parsed = cachedAtlasSchema.parse(JSON.parse(index.toString("utf8"))); + const cached = { + ...parsed, + pages: parsed.pages.map((page) => ({ + ...page, + glyphIds: page.glyphIds as GlyphId[], + atlas: withTypedCoefficients(page.atlas as unknown as CachedSlugAtlas), + })), + } satisfies CachedAtlas; + await validateCachedAtlas(file, cached, HEADER_BYTES + indexLength); + return cached; } async function validateCachedAtlas( - filePath: string, + file: FileHandle, cached: CachedAtlas, payloadOffset: number, ): Promise { @@ -534,7 +577,7 @@ async function validateCachedAtlas( compressedOffset += page.compressedLength; } - const stat = await fs.promises.stat(filePath); + const stat = await file.stat(); if (payloadOffset + compressedOffset !== stat.size) { throw new Error("cached atlas payload length does not match its index"); } @@ -589,15 +632,19 @@ function stagedPage(page: StagedCachedAtlasPage, compressedOffset: number): Cach async function payloadStart(filePath: string): Promise { const file = await fs.promises.open(filePath, "r"); try { - const header = Buffer.alloc(HEADER_BYTES); - const result = await file.read(header, 0, header.byteLength, 0); - if (result.bytesRead !== header.byteLength) throw new Error("truncated cached atlas header"); - return HEADER_BYTES + header.readUInt32LE(MAGIC.byteLength); + return await payloadStartFromFile(file); } finally { await file.close(); } } +async function payloadStartFromFile(file: FileHandle): Promise { + const header = Buffer.alloc(HEADER_BYTES); + const result = await file.read(header, 0, header.byteLength, 0); + if (result.bytesRead !== header.byteLength) throw new Error("truncated cached atlas header"); + return HEADER_BYTES + header.readUInt32LE(MAGIC.byteLength); +} + async function copyFileInto(sourcePath: string, output: fs.promises.FileHandle): Promise { for await (const chunk of fs.createReadStream(sourcePath)) { await output.writeFile(chunk); @@ -628,11 +675,27 @@ async function checksumFileRange( filePath: string, offset: number, length: number, +): Promise { + const file = await fs.promises.open(filePath, "r"); + try { + return await checksumFileHandleRange(filePath, file, offset, length); + } finally { + await file.close(); + } +} + +async function checksumFileHandleRange( + filePath: string, + file: FileHandle, + offset: number, + length: number, ): Promise { const hash = crypto.createHash("sha256"); if (length === 0) return hash.digest("hex"); for await (const chunk of fs.createReadStream(filePath, { + fd: file.fd, + autoClose: false, start: offset, end: offset + length - 1, })) { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index 49646fd5..d52997a4 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -185,6 +185,17 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { return bytes; } + function corruptAtlasCacheIndex(): void { + const cacheRoot = path.join(tmpRoot, "atlas-cache"); + const fileName = fs.readdirSync(cacheRoot).find((name) => name.endsWith(".atlas")); + if (!fileName) throw new Error("expected a published CachedAtlas"); + + const filePath = path.join(cacheRoot, fileName); + const bytes = fs.readFileSync(filePath); + bytes[12] ^= 0xff; + fs.writeFileSync(filePath, bytes); + } + async function createWorkspace( sync: SyncChannel, targetShell: ShellChannel = shell, @@ -256,7 +267,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { expect(atlas.glyphs.map((entry) => entry.glyphId)).toEqual([glyph.glyphId]); }); - it("streams requested roots and reuses the completed cached page", async () => { + it("streams requested roots through one validated cached artifact", async () => { const sync = await connectSyncLane(); const snapshot = await createWorkspace(sync); const first = createGlyphALayer(snapshot.sources[0]!.id); @@ -281,11 +292,17 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { const bytes = await streamSlugAtlas(sync, page.generation, 64, page.origin); const cached = await sync.call("workspace.slugAtlasPagePrepare", request); const cachedBytes = await streamSlugAtlas(sync, cached.generation, 64, cached.origin); + corruptAtlasCacheIndex(); + const retained = await sync.call("workspace.slugAtlasPagePrepare", request); + const retainedBytes = await streamSlugAtlas(sync, retained.generation, 64, retained.origin); expect(page.origin).toBe("native"); expect(cached.origin).toBe("cached"); + expect(retained.origin).toBe("cached"); expect(cachedBytes).toEqual(bytes); + expect(retainedBytes).toEqual(bytes); expect(cached.glyphs.map((entry) => entry.glyphId)).toEqual([secondGlyphId]); + await shell.call("workspace.close", { discard: true }); }); it("cancels native Slug production when the renderer rejects a chunk", async () => { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 0020a584..1558baab 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -19,7 +19,9 @@ import type { } from "../../shared/workspace/protocol"; import { PortByteStream } from "../../shared/workspace/PortByteStream"; import { + closeCachedAtlas, DEFAULT_ATLAS_CACHE_BYTE_BUDGET, + loadCachedAtlasPage, openCachedAtlas, pruneCachedAtlases, publishCachedAtlas, @@ -33,6 +35,7 @@ import { type CachedAtlasPageRequest, type CachedAtlasPageSink, type DocumentAllocation, + type OpenedCachedAtlas, type OpenedCachedAtlasPage, type PreparedAtlasPage, type StagedCachedAtlasPage, @@ -78,6 +81,8 @@ export class WorkspaceHost { #packageAddress: PackageAddress | null = null; #cachedGeneration = 0; #cachedPages = new Map(); + #openedCachedAtlasKey: string | null = null; + #openedCachedAtlas: OpenedCachedAtlas | null = null; #preparedPages = new Map(); #atlasBuilds = new Map(); #operations: Promise = Promise.resolve(); @@ -180,7 +185,7 @@ export class WorkspaceHost { revisionKey: this.#bridge.slugAtlasCacheRevision(), }, }; - const cached = await openCachedAtlas(this.#atlasCacheRoot, cacheRequest); + const cached = await this.#loadCachedAtlasPage(cacheRequest); if (cached) { this.#cachedGeneration += 1; if (!Number.isSafeInteger(this.#cachedGeneration)) { @@ -200,6 +205,38 @@ export class WorkspaceHost { return { ...descriptor, origin: "native" }; } + async #loadCachedAtlasPage( + request: CachedAtlasPageRequest, + ): Promise { + const openedKey = cachedAtlasOpenKey(request); + if (this.#openedCachedAtlasKey !== openedKey) { + await this.#closeOpenedCachedAtlas(); + this.#openedCachedAtlasKey = openedKey; + this.#openedCachedAtlas = await openCachedAtlas(this.#atlasCacheRoot, request); + } + + const opened = this.#openedCachedAtlas; + if (!opened) return null; + + const page = await loadCachedAtlasPage(opened, request); + if (page) return page; + + await this.#closeOpenedCachedAtlas(); + return null; + } + + async #closeOpenedCachedAtlas(): Promise { + const opened = this.#openedCachedAtlas; + this.#openedCachedAtlas = null; + if (!opened) return; + + try { + await closeCachedAtlas(opened); + } catch (error) { + console.error("failed to close cached Slug atlas", error); + } + } + async #streamSlugAtlas( generation: number, maximumLength: number, @@ -367,6 +404,8 @@ export class WorkspaceHost { if (publishedBytes === null) return; this.#atlasBuilds.delete(buildKey); + await this.#closeOpenedCachedAtlas(); + this.#openedCachedAtlasKey = null; await pruneCachedAtlases(this.#atlasCacheRoot, this.#atlasCacheByteBudget); } @@ -483,7 +522,7 @@ export class WorkspaceHost { return { path: result.path, format: "ttf" }; } - #close(discard: boolean): null { + async #close(discard: boolean): Promise { const state = this.#documentState(); if (!state) return null; if (state.dirty && !discard) { @@ -493,7 +532,16 @@ export class WorkspaceHost { const documentId = state.documentId; const address = this.#packageAddress; + for (const page of this.#cachedPages.values()) { + try { + await cancelCachedAtlasPage(page); + } catch (error) { + console.error("failed to cancel cached Slug atlas page", error); + } + } this.#cachedPages.clear(); + await this.#closeOpenedCachedAtlas(); + this.#openedCachedAtlasKey = null; this.#preparedPages.clear(); this.#discardAtlasBuildsExcept(null); this.#bridge.closeWorkspace(); @@ -555,6 +603,15 @@ export class WorkspaceHost { } } +function cachedAtlasOpenKey(request: CachedAtlasPageRequest): string { + return JSON.stringify([ + request.key.documentKey, + request.key.revisionKey, + request.alignment, + request.pageCount, + ]); +} + function cachedAtlasBuildKey(request: CachedAtlasPageRequest): string { return JSON.stringify([ request.key.documentKey, diff --git a/apps/desktop/src/utility/workspace/types.ts b/apps/desktop/src/utility/workspace/types.ts index e237308f..0b9dfc97 100644 --- a/apps/desktop/src/utility/workspace/types.ts +++ b/apps/desktop/src/utility/workspace/types.ts @@ -1,4 +1,5 @@ import type { GlyphId, SlugAtlas } from "@shift/types"; +import type { FileHandle } from "node:fs/promises"; import type { ByteReadableStream, WorkspacePackageIdentity } from "../../shared/workspace/protocol"; /** Opaque key for one authored revision's disposable Slug pages. */ @@ -86,6 +87,14 @@ export type CachedAtlasFile = { touched: number; }; +/** One validated cache artifact whose index and file stay open across page loads. */ +export type OpenedCachedAtlas = { + atlas: CachedAtlas; + filePath: string; + file: FileHandle; + payloadOffset: number; +}; + /** Validated cached page ready for bounded decompression. */ export type OpenedCachedAtlasPage = { atlas: CachedSlugAtlas; From 0d658ce6046aa6edcc6a390fe9b1275bc04ce56f Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 17:05:58 +0000 Subject: [PATCH 6/8] Remove global scans from Grid paging --- apps/desktop/src/main/docs/DOCS.md | 2 +- .../components/home/GlyphCatalogController.ts | 44 +++++++++++-------- .../renderer/src/lib/graphics/docs/DOCS.md | 2 +- .../utility/workspace/WorkspaceHost.test.ts | 10 +++++ .../src/utility/workspace/WorkspaceHost.ts | 27 +++++++++--- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index e23db260..6c09921d 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -11,7 +11,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. - **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty documents are pruned. - **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. -- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Stale, corrupt, and evicted entries rebuild. +- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. ## Codemap diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 6f28fc09..918ef6be 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -44,6 +44,7 @@ export class GlyphCatalogController { readonly #fontEffect: Effect; readonly #invalidGlyphIds = new Set(); readonly #replacementPageIndices = new Set(); + readonly #pageIndexByGlyph = new Map(); #targetFrame: GridFrame | null = null; #activeFrame: GridFrame | null = null; @@ -167,8 +168,18 @@ export class GlyphCatalogController { #invalidate(glyphIds: readonly GlyphId[] | null, fontGlyphIds: readonly GlyphId[]): void { const directoryChanged = !sameGlyphIds(this.#fontGlyphIds, fontGlyphIds); this.#fontGlyphIds = fontGlyphIds; + if (directoryChanged) { + this.#pageIndexByGlyph.clear(); + for (let glyphIndex = 0; glyphIndex < fontGlyphIds.length; glyphIndex += 1) { + this.#pageIndexByGlyph.set( + fontGlyphIds[glyphIndex]!, + Math.floor(glyphIndex / ATLAS_PAGE_ROOT_COUNT), + ); + } + } - if (glyphIds === null || directoryChanged) { + const invalidateAll = glyphIds === null || directoryChanged; + if (invalidateAll) { this.#invalidGlyphIds.clear(); for (const glyphId of fontGlyphIds) this.#invalidGlyphIds.add(glyphId); if (this.#targetFrame) { @@ -178,16 +189,21 @@ export class GlyphCatalogController { }; } } else { - const fontGlyphIdSet = new Set(fontGlyphIds); for (const glyphId of glyphIds) { - if (fontGlyphIdSet.has(glyphId)) this.#invalidGlyphIds.add(glyphId); + if (this.#pageIndexByGlyph.has(glyphId)) this.#invalidGlyphIds.add(glyphId); } } this.#replacementPageIndices.clear(); - for (const glyphId of this.#invalidGlyphIds) { - const pageIndex = this.#pageIndex(glyphId); - if (pageIndex !== null) this.#replacementPageIndices.add(pageIndex); + if (invalidateAll) { + for (let pageIndex = 0; pageIndex < this.#pageCount(); pageIndex += 1) { + this.#replacementPageIndices.add(pageIndex); + } + } else { + for (const glyphId of this.#invalidGlyphIds) { + const pageIndex = this.#pageIndex(glyphId); + if (pageIndex !== null) this.#replacementPageIndices.add(pageIndex); + } } this.#visibleBuild?.abort(new Error("resident visible frame changed")); @@ -406,8 +422,7 @@ export class GlyphCatalogController { } #pageIndex(glyphId: GlyphId): number | null { - const glyphIndex = this.#fontGlyphIds.indexOf(glyphId); - return glyphIndex < 0 ? null : Math.floor(glyphIndex / ATLAS_PAGE_ROOT_COUNT); + return this.#pageIndexByGlyph.get(glyphId) ?? null; } #pageCount(): number { @@ -567,17 +582,10 @@ export class GlyphCatalogController { #updateFullyResident(): void { const layer = this.#layer; - const complete = - Boolean(layer) && - this.#fontGlyphIds.every( - (glyphId) => !this.#invalidGlyphIds.has(glyphId) && Boolean(layer?.hasGlyphs([glyphId])), - ); + const complete = Boolean(layer) && this.#invalidGlyphIds.size === 0; + const residentGlyphCount = layer ? this.#fontGlyphIds.length - this.#invalidGlyphIds.size : 0; this.#glyphCanvas.dataset.fullyResident = String(complete); - this.#glyphCanvas.dataset.residentGlyphCount = String( - this.#fontGlyphIds.filter( - (glyphId) => !this.#invalidGlyphIds.has(glyphId) && Boolean(layer?.hasGlyphs([glyphId])), - ).length, - ); + this.#glyphCanvas.dataset.residentGlyphCount = String(residentGlyphCount); this.#glyphCanvas.dataset.targetGlyphCount = String(this.#fontGlyphIds.length); const activeLayout = this.#activeFrame ? this.#layout(this.#activeFrame) : null; this.#glyphCanvas.dataset.previewHeight = String(activeLayout?.previewHeight ?? 0); diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index 0ffeec5e..c69d360b 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -77,7 +77,7 @@ editor/rendering/markers/ ### Resident catalog lifecycle -`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency selects every deterministic 256-root directory page intersecting the current viewport, uploads the complete set, and then `#refreshComplete` fills remaining pages while yielding between requests. `SlugRenderer.loadPages` constructs all GPU page resources before synchronously replacing glyph mappings, so a viewport crossing a page boundary cannot expose a mixed set. Local edits and global axis/source changes leave active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes newly visible pages. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. +`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Directory revisions build one glyph-to-page index; invalidation and visible lookup never linearly search the directory per root. Initial residency selects every deterministic 256-root directory page intersecting the current viewport, uploads the complete set, and then `#refreshComplete` fills remaining pages while yielding between requests. `SlugRenderer.loadPages` constructs all GPU page resources before synchronously replacing glyph mappings, so a viewport crossing a page boundary cannot expose a mixed set. Local edits and global axis/source changes leave active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes newly visible pages. The invalid-root set is the residency authority: successful page installation removes its requested roots, complete residency means the set is empty, and the resident count is derived without rescanning GPU mappings. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. Every page reports all-source `SlugPreviewExtents`. The controller monotonically merges those bounds during the active generation, and `GlyphCatalogLayout` expands shared cell width, preview height, and row pitch using the existing metrics-derived pixels-per-em ratio. Oversized glyphs therefore retain the same scale rather than being individually fitted or clipped to the metrics box. diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index d52997a4..f17409a9 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -302,6 +302,16 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { expect(cachedBytes).toEqual(bytes); expect(retainedBytes).toEqual(bytes); expect(cached.glyphs.map((entry) => entry.glyphId)).toEqual([secondGlyphId]); + + await applyWorkspace(sync, { + intents: [{ kind: "setXAdvance", setXAdvance: { layerId: secondLayerId, width: 700 } }], + }); + const changed = await sync.call("workspace.slugAtlasPagePrepare", request); + expect(changed.origin).toBe("native"); + await sync.call("workspace.slugAtlasPageDiscard", { + generation: changed.generation, + origin: changed.origin, + }); await shell.call("workspace.close", { discard: true }); }); diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 1558baab..50a37dcb 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -79,6 +79,7 @@ export class WorkspaceHost { #sync: ChannelServer | null = null; #documentId: string | null = null; #packageAddress: PackageAddress | null = null; + #atlasCacheRevision: string | null = null; #cachedGeneration = 0; #cachedPages = new Map(); #openedCachedAtlasKey: string | null = null; @@ -130,17 +131,20 @@ export class WorkspaceHost { "workspace.apply": ({ intents, label }) => this.#serialize(() => { const applied = this.#bridge.apply(intents, label); + this.#atlasCacheRevision = null; return { applied, documentState: this.#emitDocumentChanged() }; }), "workspace.undo": () => this.#serialize(() => { const applied = this.#bridge.undo(); + if (applied) this.#atlasCacheRevision = null; const documentState = applied ? this.#emitDocumentChanged() : this.#documentState(); return { applied, documentState }; }), "workspace.redo": () => this.#serialize(() => { const applied = this.#bridge.redo(); + if (applied) this.#atlasCacheRevision = null; const documentState = applied ? this.#emitDocumentChanged() : this.#documentState(); return { applied, documentState }; }), @@ -182,7 +186,7 @@ export class WorkspaceHost { ...request, key: { documentKey: this.#requireDocumentId(), - revisionKey: this.#bridge.slugAtlasCacheRevision(), + revisionKey: this.#currentAtlasCacheRevision(), }, }; const cached = await this.#loadCachedAtlasPage(cacheRequest); @@ -213,6 +217,13 @@ export class WorkspaceHost { await this.#closeOpenedCachedAtlas(); this.#openedCachedAtlasKey = openedKey; this.#openedCachedAtlas = await openCachedAtlas(this.#atlasCacheRoot, request); + if (this.#openedCachedAtlas) { + try { + await pruneCachedAtlases(this.#atlasCacheRoot, this.#atlasCacheByteBudget); + } catch (error) { + console.error("failed to prune cached Slug atlases", error); + } + } } const opened = this.#openedCachedAtlas; @@ -279,11 +290,6 @@ export class WorkspaceHost { try { await stream.send(cached.stream, undefined, maximumLength); - try { - await pruneCachedAtlases(this.#atlasCacheRoot, this.#atlasCacheByteBudget); - } catch (error) { - console.error("failed to prune cached Slug atlases", error); - } return null; } finally { stream.close(); @@ -424,6 +430,7 @@ export class WorkspaceHost { this.#bridge.createUntitledWorkspace(document.storePath); this.#bridge.setDocumentId(document.documentId); this.#documentId = document.documentId; + this.#atlasCacheRevision = null; this.#packageAddress = null; return this.#emitDocumentChanged(); @@ -466,6 +473,7 @@ export class WorkspaceHost { this.#bridge.openWorkspace(sourcePath, document.storePath); this.#bridge.setDocumentId(document.documentId); this.#documentId = document.documentId; + this.#atlasCacheRevision = null; this.#packageAddress = null; return this.#emitDocumentChanged(); @@ -484,6 +492,7 @@ export class WorkspaceHost { #adoptDocument(document: DocumentAllocation, address: PackageAddress | null): void { this.#documentId = document.documentId; + this.#atlasCacheRevision = null; this.#packageAddress = address; } @@ -546,6 +555,7 @@ export class WorkspaceHost { this.#discardAtlasBuildsExcept(null); this.#bridge.closeWorkspace(); this.#documentId = null; + this.#atlasCacheRevision = null; this.#packageAddress = null; if (!address || discard) { @@ -594,6 +604,11 @@ export class WorkspaceHost { return run; } + #currentAtlasCacheRevision(): string { + this.#atlasCacheRevision ??= this.#bridge.slugAtlasCacheRevision(); + return this.#atlasCacheRevision; + } + #requireDocumentId(): string { if (this.#documentId === null) { throw new Error("no workspace is open"); From c6be631fa646e978eed7132cba48a36a2aaa7199 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 13:42:24 -0400 Subject: [PATCH 7/8] Fit oversized glyphs within fixed Grid cells --- apps/desktop/e2e/gpu.spec.ts | 72 ++++++++++++-- .../components/home/GlyphCatalogCanvas.tsx | 4 +- .../components/home/GlyphCatalogController.ts | 98 +++---------------- .../src/components/home/GlyphGrid.tsx | 20 +--- .../home/GlyphPreviewLayout.test.ts | 7 -- .../src/components/home/GlyphPreviewLayout.ts | 21 ++-- .../home/glyphCatalogLayout.test.ts | 39 +------- .../src/components/home/glyphCatalogLayout.ts | 37 ++----- .../graphics/backends/ResidentGlyphLayer.ts | 29 +----- .../renderer/src/lib/graphics/docs/DOCS.md | 4 +- .../src/renderer/src/lib/slug/SlugRenderer.ts | 1 + .../src/lib/slug/SlugRendererResources.ts | 33 ++++++- .../src/renderer/src/types/glyphCatalog.ts | 15 +-- .../src/renderer/src/types/glyphPreview.ts | 7 +- crates/shift-bridge/docs/DOCS.md | 4 +- crates/shift-slug/docs/DOCS.md | 4 +- crates/shift-slug/shaders/slug-variable.wgsl | 32 +++--- 17 files changed, 155 insertions(+), 272 deletions(-) diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index e4897b3d..10e4039d 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -375,13 +375,24 @@ test.describe("Resident catalog GPU", () => { expect(state.hiddenTransitions).toBe(0); }); - test("expands every preview cell for outlines outside the font metrics", async ({ page }) => { + test("fits oversized outlines without resizing cells while scrubbing an axis", async ({ + page, + }) => { const scrollViewport = page.getByLabel("Glyph catalog"); - const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); + const catalogSurface = scrollViewport.locator(".."); + const glyphCanvas = catalogSurface.locator("canvas").first(); await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { timeout: 30_000, }); - const initialHeight = Number(await glyphCanvas.getAttribute("data-preview-height")); + const initialGeometry = await scrollViewport.evaluate((element) => { + const canvas = element.parentElement?.querySelector("canvas"); + if (!canvas) throw new Error("Expected resident glyph canvas"); + + return { + previewHeight: Number(canvas.dataset.previewHeight), + scrollHeight: element.scrollHeight, + }; + }); await navigateToEditor(page, "53"); await page.evaluate(async () => { @@ -405,12 +416,47 @@ test.describe("Resident catalog GPU", () => { }); await page.getByRole("button", { name: "Display all glyphs" }).click(); await page.waitForURL(/#\/home/); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); - await expect - .poll(async () => Number(await glyphCanvas.getAttribute("data-preview-height"))) - .toBeGreaterThan(initialHeight); + const variable = await createVariableDesignspace(page); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + const geometrySamples = await page.evaluate(async ({ axisId }) => { + const workspace = window.shift; + const viewport = document.querySelector('[aria-label="Glyph catalog"]'); + const canvas = viewport?.parentElement?.querySelector("canvas"); + if (!workspace || !viewport || !canvas) throw new Error("Expected Grid runtime"); + + const samples: Array<{ previewHeight: number; scrollHeight: number }> = []; + for (const value of [400, 500, 650, 800, 900, 650, 400]) { + workspace.editor.setDesignLocation(new Map([[axisId, value]])); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push({ + previewHeight: Number(canvas.dataset.previewHeight), + scrollHeight: viewport.scrollHeight, + }); + } + return samples; + }, variable); + + expect(geometrySamples).toEqual( + Array.from({ length: geometrySamples.length }, () => initialGeometry), + ); await expect(glyphCanvas).toBeVisible(); - expect(Number(await glyphCanvas.getAttribute("data-preview-horizontal"))).toBeGreaterThan(0); + const renderedFrame = await catalogSurface.screenshot(); + const visibility = await glyphCanvas.evaluate((canvas) => { + const previous = canvas.style.visibility; + canvas.style.visibility = "hidden"; + return previous; + }); + const frameWithoutGlyphs = await catalogSurface.screenshot(); + await glyphCanvas.evaluate((canvas, previous) => { + canvas.style.visibility = previous; + }, visibility); + expect(renderedFrame.equals(frameWithoutGlyphs)).toBe(false); }); }); @@ -435,6 +481,18 @@ async function createVariableDesignspace( await font.editCoordinator.settled(); const sourceId = font.createSource("Bold", { values: { [axisId]: 900 } }); await font.editCoordinator.settled(); + const source = font.sources.find((candidate) => candidate.id === sourceId); + if (!source || source.metricValues.length === 0) { + throw new Error("Expected Bold source metrics"); + } + await font.updateSource({ + ...source, + metricValues: source.metricValues.map((value) => ({ + ...value, + position: value.position * 2, + })), + }); + await font.editCoordinator.settled(); return { axisId, sourceId }; }); } diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx b/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx index 34bb4b1e..c0490762 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogCanvas.tsx @@ -17,7 +17,6 @@ export function GlyphCatalogCanvas({ openGlyph, onFirstFrame, onUnavailable, - onPreviewExtentsChange, }: GlyphCatalogCanvasProps) { const editor = useEditor(); const { themeName } = useTheme(); @@ -51,7 +50,6 @@ export function GlyphCatalogCanvas({ if (nextReady) onFirstFrame(); }, onUnavailable, - onPreviewExtentsChange, ); controllerRef.current = controller; @@ -59,7 +57,7 @@ export function GlyphCatalogCanvas({ controllerRef.current = null; controller.destroy(); }; - }, [containerRef, editor.font, onFirstFrame, onPreviewExtentsChange, onUnavailable, openGlyph]); + }, [containerRef, editor.font, onFirstFrame, onUnavailable, openGlyph]); useLayoutEffect(() => { controllerRef.current?.update( diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 918ef6be..6817ccd7 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -1,5 +1,5 @@ import type { Point2D } from "@shift/geo"; -import type { GlyphId, SlugPreviewExtents } from "@shift/types"; +import type { GlyphId } from "@shift/types"; import { GlyphPreviewLayout } from "./GlyphPreviewLayout"; import { GlyphCatalogLayout } from "./glyphCatalogLayout"; import { GlyphCatalogOverlay } from "./GlyphCatalogOverlay"; @@ -15,17 +15,11 @@ import type { GlyphCatalogControllerFrame, GlyphCatalogFrame, GlyphCatalogItem, - GridFrame, GridReadiness, } from "@/types/glyphCatalog"; import type { GlyphPreviewInstance } from "@/types/glyphPreview"; const ATLAS_PAGE_ROOT_COUNT = 256; -const EMPTY_PREVIEW_EXTENTS: SlugPreviewExtents = { - horizontal: 0, - minimumY: 0, - maximumY: 0, -}; /** Owns catalog DOM events, visible-first atlas replacement, and frame scheduling. */ export class GlyphCatalogController { @@ -37,7 +31,6 @@ export class GlyphCatalogController { readonly #openGlyph: (glyph: GlyphCatalogItem) => Promise; readonly #onReadyChange: (ready: boolean) => void; readonly #onUnavailable: () => void; - readonly #onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void; readonly #overlay: GlyphCatalogOverlay; readonly #frames = new FrameHandler(); readonly #resizeObserver: ResizeObserver; @@ -46,8 +39,8 @@ export class GlyphCatalogController { readonly #replacementPageIndices = new Set(); readonly #pageIndexByGlyph = new Map(); - #targetFrame: GridFrame | null = null; - #activeFrame: GridFrame | null = null; + #targetFrame: GlyphCatalogControllerFrame | null = null; + #activeFrame: GlyphCatalogControllerFrame | null = null; #fontGlyphIds: readonly GlyphId[] = []; #layer: ResidentGlyphLayer | null = null; /** Device initialization; aborted work retains this slot until it settles. */ @@ -70,7 +63,6 @@ export class GlyphCatalogController { openGlyph: (glyph: GlyphCatalogItem) => Promise, onReadyChange: (ready: boolean) => void, onUnavailable: () => void, - onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void, ) { this.#container = container; this.#glyphCanvas = glyphCanvas; @@ -80,7 +72,6 @@ export class GlyphCatalogController { this.#openGlyph = openGlyph; this.#onReadyChange = onReadyChange; this.#onUnavailable = onUnavailable; - this.#onPreviewExtentsChange = onPreviewExtentsChange; this.#overlay = new GlyphCatalogOverlay(overlayCanvas); this.#glyphCanvas.dataset.fullyResident = "false"; this.#glyphCanvas.dataset.gridReadiness = "Initial" satisfies GridReadiness; @@ -111,9 +102,7 @@ export class GlyphCatalogController { update(frame: GlyphCatalogControllerFrame, inputContainer: HTMLDivElement | null): void { const previousTarget = this.#targetFrame; - const previewExtents = previousTarget?.previewExtents ?? - this.#activeFrame?.previewExtents ?? { ...EMPTY_PREVIEW_EXTENTS }; - this.#targetFrame = { ...frame, previewExtents }; + this.#targetFrame = frame; if ( !previousTarget || @@ -182,12 +171,6 @@ export class GlyphCatalogController { if (invalidateAll) { this.#invalidGlyphIds.clear(); for (const glyphId of fontGlyphIds) this.#invalidGlyphIds.add(glyphId); - if (this.#targetFrame) { - this.#targetFrame = { - ...this.#targetFrame, - previewExtents: { ...EMPTY_PREVIEW_EXTENTS }, - }; - } } else { for (const glyphId of glyphIds) { if (this.#pageIndexByGlyph.has(glyphId)) this.#invalidGlyphIds.add(glyphId); @@ -292,24 +275,17 @@ export class GlyphCatalogController { try { const pageRequests = this.#pageRequests(glyphIds); - const pageExtents = await layer.loadPages(pageRequests, visibleBuild.signal); + await layer.loadPages(pageRequests, visibleBuild.signal); if (this.#disposed || this.#visibleBuild !== visibleBuild || visibleBuild.signal.aborted) { return; } const latestTarget = this.#targetFrame; if (!latestTarget) return; - const targetExtents = mergePreviewExtents(latestTarget.previewExtents, pageExtents); - const presentedExtents = mergePreviewExtents( - this.#activeFrame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, - targetExtents, - ); - this.#targetFrame = { ...latestTarget, previewExtents: targetExtents }; - this.#activeFrame = { ...latestTarget, previewExtents: presentedExtents }; + this.#activeFrame = latestTarget; for (const request of pageRequests) { for (const glyphId of request.glyphIds) this.#invalidGlyphIds.delete(glyphId); } - this.#onPreviewExtentsChange(presentedExtents); this.#needsRedraw = true; this.#updateFullyResident(); this.redraw(); @@ -365,25 +341,10 @@ export class GlyphCatalogController { if (!needsReplacement) continue; const pageIndex = start / ATLAS_PAGE_ROOT_COUNT; - const pageExtents = await this.#layer.loadPages( - [this.#pageRequest(pageIndex)], - completeBuild.signal, - ); + await this.#layer.loadPages([this.#pageRequest(pageIndex)], completeBuild.signal); if (completeBuild.signal.aborted) break; - const latestTarget = this.#targetFrame; - if (!latestTarget) break; - const targetExtents = mergePreviewExtents(latestTarget.previewExtents, pageExtents); - const presentedExtents = mergePreviewExtents( - this.#activeFrame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, - targetExtents, - ); - this.#targetFrame = { ...latestTarget, previewExtents: targetExtents }; - if (this.#activeFrame) { - this.#activeFrame = { ...this.#activeFrame, previewExtents: presentedExtents }; - } for (const glyphId of pageGlyphIds) this.#invalidGlyphIds.delete(glyphId); - this.#onPreviewExtentsChange(presentedExtents); this.#needsRedraw = true; this.#updateFullyResident(); this.redraw(); @@ -455,13 +416,10 @@ export class GlyphCatalogController { } #layout(frame = this.#activeFrame ?? this.#targetFrame): GlyphCatalogLayout { - const metrics = frame?.metrics; return new GlyphCatalogLayout( this.#container.clientWidth, this.#container.clientHeight, frame?.glyphs.length ?? 0, - metrics ?? fallbackMetrics(), - frame?.previewExtents ?? EMPTY_PREVIEW_EXTENTS, ); } @@ -520,19 +478,15 @@ export class GlyphCatalogController { frame.scrollTop + frame.layout.viewportHeight > 0; if (instances.length > 0 || input.glyphs.length === 0 || !catalogIntersectsViewport) { - const [viewHeight, fontTop] = GlyphPreviewLayout.fontViewport( - input.metrics, - input.previewExtents, - ); + const [viewHeight, metricsTop] = GlyphPreviewLayout.fontViewport(input.metrics); layer.draw({ location: input.location, axes: input.axes, instances, style: { - viewHeight, - fontTop, - previewHeight: frame.layout.previewHeight * ratio, - sideMargin: GlyphPreviewLayout.sideMargin(input.metrics, input.previewExtents), + defaultPixelsPerEm: (frame.layout.previewHeight * ratio) / Math.max(1, viewHeight), + metricsTop, + metricsBottom: metricsTop - viewHeight, color: parseCssColor(getComputedStyle(this.#container).color), }, viewportWidth: this.#glyphCanvas.width, @@ -589,9 +543,6 @@ export class GlyphCatalogController { this.#glyphCanvas.dataset.targetGlyphCount = String(this.#fontGlyphIds.length); const activeLayout = this.#activeFrame ? this.#layout(this.#activeFrame) : null; this.#glyphCanvas.dataset.previewHeight = String(activeLayout?.previewHeight ?? 0); - this.#glyphCanvas.dataset.previewHorizontal = String( - this.#activeFrame?.previewExtents.horizontal ?? 0, - ); let readiness: GridReadiness = "Initial"; if (this.#activeFrame) { @@ -675,33 +626,6 @@ export class GlyphCatalogController { } } -function mergePreviewExtents( - current: SlugPreviewExtents, - next: SlugPreviewExtents, -): SlugPreviewExtents { - return { - horizontal: Math.max(current.horizontal, next.horizontal), - minimumY: Math.min(current.minimumY, next.minimumY), - maximumY: Math.max(current.maximumY, next.maximumY), - }; -} - function sameGlyphIds(left: readonly GlyphId[], right: readonly GlyphId[]): boolean { return left.length === right.length && left.every((glyphId, index) => glyphId === right[index]); } - -function fallbackMetrics() { - return { - unitsPerEm: 1000, - metricValues: [], - ascender: 800, - descender: -200, - xHeight: 500, - capHeight: 700, - baseline: 0, - italicAngle: 0, - lineGap: 0, - underlinePosition: -100, - underlineThickness: 50, - }; -} diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 9609968e..3d2ebc0d 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -1,4 +1,3 @@ -import type { SlugPreviewExtents } from "@shift/types"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router"; import { GlyphCatalogCanvas } from "./GlyphCatalogCanvas"; @@ -24,23 +23,11 @@ export const GlyphGrid = memo(function GlyphGrid() { readonly [width: number, height: number] >([0, 0]); const [catalogReady, setCatalogReady] = useState(false); - const [previewExtents, setPreviewExtents] = useState({ - horizontal: 0, - minimumY: 0, - maximumY: 0, - }); - const metrics = useMemo(() => font.metricsAtLocation(location), [font, location]); const layout = useMemo( - () => - new GlyphCatalogLayout( - viewportWidth, - viewportHeight, - filteredGlyphs.length, - metrics, - previewExtents, - ), - [filteredGlyphs.length, metrics, previewExtents, viewportHeight, viewportWidth], + () => new GlyphCatalogLayout(viewportWidth, viewportHeight, filteredGlyphs.length), + [filteredGlyphs.length, viewportHeight, viewportWidth], ); + const metrics = useMemo(() => font.metricsAtLocation(location), [font, location]); const axes = font.getAxes(); const sourceId = font.sourceAt(location)?.id ?? null; const initialMeasurementLoggedRef = useRef(false); @@ -121,7 +108,6 @@ export const GlyphGrid = memo(function GlyphGrid() { openGlyph={handleCellClick} onFirstFrame={handleCatalogReady} onUnavailable={handleCatalogUnavailable} - onPreviewExtentsChange={setPreviewExtents} /> ); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts index 23261116..b64dd212 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.test.ts @@ -31,11 +31,4 @@ describe("Glyph preview layout", () => { expect(layout.viewBox).toBe("0 -1000 1 1250"); expect(layout.width).toBe(75); }); - - it("extends the shared viewport without changing its font-space scale", () => { - const extents = { horizontal: 200, minimumY: -500, maximumY: 1500 }; - - expect(GlyphPreviewLayout.fontViewport(METRICS, extents)).toEqual([2000, 1500]); - expect(GlyphPreviewLayout.sideMargin(METRICS, extents)).toBe(200); - }); }); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts index aeab5ee0..7b9b2417 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreviewLayout.ts @@ -1,4 +1,4 @@ -import type { SlugPreviewExtents, SourceMetrics } from "@shift/types"; +import type { SourceMetrics } from "@shift/types"; const MARGIN_TOP_RATIO = 0.2; const MARGIN_BOTTOM_RATIO = 0.05; @@ -22,22 +22,17 @@ export class GlyphPreviewLayout { } /** Shared horizontal margin used by fallback and resident previews. */ - static sideMargin(metrics: SourceMetrics, previewExtents?: SlugPreviewExtents): number { - return Math.max(metrics.unitsPerEm * MARGIN_SIDE_RATIO, previewExtents?.horizontal ?? 0); + static sideMargin(metrics: SourceMetrics): number { + return metrics.unitsPerEm * MARGIN_SIDE_RATIO; } /** Shared font-space viewport used by fallback and resident previews. */ - static fontViewport( - metrics: SourceMetrics, - previewExtents?: SlugPreviewExtents, - ): readonly [viewHeight: number, fontTop: number] { + static fontViewport(metrics: SourceMetrics): readonly [viewHeight: number, fontTop: number] { const marginTop = metrics.unitsPerEm * MARGIN_TOP_RATIO; const marginBottom = metrics.unitsPerEm * MARGIN_BOTTOM_RATIO; - const metricsTop = metrics.ascender + marginTop; - const metricsBottom = metrics.descender - marginBottom; - const fontTop = Math.max(metricsTop, previewExtents?.maximumY ?? metricsTop); - const fontBottom = Math.min(metricsBottom, previewExtents?.minimumY ?? metricsBottom); - - return [fontTop - fontBottom, fontTop]; + return [ + metrics.ascender - metrics.descender + marginTop + marginBottom, + metrics.ascender + marginTop, + ]; } } diff --git a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts index c51499c4..1c2ed388 100644 --- a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts +++ b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts @@ -1,23 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { GlyphId, GlyphName, SlugPreviewExtents, SourceMetrics } from "@shift/types"; +import type { GlyphId, GlyphName } from "@shift/types"; import type { GlyphCatalogItem } from "@/types/glyphCatalog"; import { GlyphCatalogLayout } from "./glyphCatalogLayout"; -const METRICS: SourceMetrics = { - unitsPerEm: 1000, - metricValues: [], - ascender: 800, - descender: -200, - xHeight: 500, - capHeight: 700, - baseline: 0, - italicAngle: 0, - lineGap: 0, - underlinePosition: -100, - underlineThickness: 50, -}; -const NO_OVERFLOW: SlugPreviewExtents = { horizontal: 0, minimumY: 0, maximumY: 0 }; - function catalog(count: number): GlyphCatalogItem[] { return Array.from({ length: count }, (_, index) => ({ id: `glyph-${index}` as GlyphId, @@ -27,13 +12,8 @@ function catalog(count: number): GlyphCatalogItem[] { })); } -function layout( - width: number, - height: number, - glyphCount: number, - previewExtents = NO_OVERFLOW, -): GlyphCatalogLayout { - return new GlyphCatalogLayout(width, height, glyphCount, METRICS, previewExtents); +function layout(width: number, height: number, glyphCount: number): GlyphCatalogLayout { + return new GlyphCatalogLayout(width, height, glyphCount); } describe("canvas-owned Glyph catalog layout", () => { @@ -103,17 +83,4 @@ describe("canvas-owned Glyph catalog layout", () => { expect(result.frame(glyphs, scrollTop).cells.length).toBeGreaterThan(0); } }); - - it("expands every cell for font-wide bounds without changing pixels per em", () => { - const result = layout(500, 240, 9, { - horizontal: 200, - minimumY: -500, - maximumY: 1500, - }); - - expect(result.previewHeight).toBe(120); - expect(result.columns).toBe(3); - expect(result.rowPitch).toBe(168); - expect(result.totalHeight).toBe(544); - }); }); diff --git a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts index 70b1401b..f28ff8ca 100644 --- a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts +++ b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.ts @@ -1,6 +1,4 @@ import { Rect, type Point2D } from "@shift/geo"; -import type { SlugPreviewExtents, SourceMetrics } from "@shift/types"; -import { GlyphPreviewLayout } from "./GlyphPreviewLayout"; import type { GlyphCatalogCell, GlyphCatalogCellArea, @@ -13,11 +11,12 @@ const VIEWPORT_PADDING = 20; const GRID_INSET = 16; const COLUMN_GAP = 8; const NOMINAL_CELL_WIDTH = 100; +const ROW_PITCH = 123; const PREVIEW_HEIGHT = 75; const PREVIEW_CONTENT_INSET = 16; const NAME_GAP = 8; const NAME_HEIGHT = 28; -const ROW_GAP = 12; +const CELL_HEIGHT = PREVIEW_HEIGHT + NAME_GAP + NAME_HEIGHT; /** Immutable screen-space layout for one glyph catalog viewport. */ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { @@ -33,42 +32,27 @@ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { readonly gridLeft = VIEWPORT_PADDING + GRID_INSET; readonly gridWidth: number; readonly columnGap = COLUMN_GAP; - readonly rowPitch: number; - readonly previewHeight: number; + readonly rowPitch = ROW_PITCH; + readonly previewHeight = PREVIEW_HEIGHT; readonly previewContentInset = PREVIEW_CONTENT_INSET; readonly nameGap = NAME_GAP; readonly nameHeight = NAME_HEIGHT; - constructor( - viewportWidth: number, - viewportHeight: number, - glyphCount: number, - metrics: SourceMetrics, - previewExtents: SlugPreviewExtents, - ) { + constructor(viewportWidth: number, viewportHeight: number, glyphCount: number) { this.viewportWidth = finiteNonNegative(viewportWidth); this.viewportHeight = finiteNonNegative(viewportHeight); this.glyphCount = Math.max(0, Math.floor(finiteNonNegative(glyphCount))); - - const [baseViewHeight] = GlyphPreviewLayout.fontViewport(metrics); - const [expandedViewHeight] = GlyphPreviewLayout.fontViewport(metrics, previewExtents); - const pixelsPerEm = PREVIEW_HEIGHT / Math.max(1, baseViewHeight); - this.previewHeight = expandedViewHeight * pixelsPerEm; - this.rowPitch = this.previewHeight + NAME_GAP + NAME_HEIGHT + ROW_GAP; - - const horizontalOverflow = 2 * previewExtents.horizontal * pixelsPerEm; - const nominalCellWidth = NOMINAL_CELL_WIDTH + horizontalOverflow; this.gridWidth = Math.max(0, this.viewportWidth - 2 * this.gridLeft); this.columns = this.gridWidth > 0 - ? Math.max(1, Math.floor((this.gridWidth + COLUMN_GAP) / (nominalCellWidth + COLUMN_GAP))) + ? Math.max(1, Math.floor((this.gridWidth + COLUMN_GAP) / (NOMINAL_CELL_WIDTH + COLUMN_GAP))) : 0; this.cellWidth = this.columns > 0 ? (this.gridWidth - Math.max(0, this.columns - 1) * COLUMN_GAP) / this.columns : 0; this.rowCount = this.columns > 0 ? Math.ceil(this.glyphCount / this.columns) : 0; - this.totalHeight = this.rowCount > 0 ? 2 * VIEWPORT_PADDING + this.rowCount * this.rowPitch : 0; + this.totalHeight = this.rowCount > 0 ? 2 * VIEWPORT_PADDING + this.rowCount * ROW_PITCH : 0; } /** Derives only the cells intersecting the current native scroll viewport. */ @@ -122,12 +106,7 @@ export class GlyphCatalogLayout implements GlyphCatalogLayoutMetrics { cells.push({ catalogIndex, glyph, - cellRect: Rect.fromXYWH( - x, - y, - this.cellWidth, - this.previewHeight + this.nameGap + this.nameHeight, - ), + cellRect: Rect.fromXYWH(x, y, this.cellWidth, CELL_HEIGHT), previewRect, previewContentRect, nameRect, diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts index 079eed08..adc250f5 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts @@ -1,4 +1,4 @@ -import type { GlyphId, SlugPreviewExtents } from "@shift/types"; +import type { GlyphId } from "@shift/types"; import type { SlugAtlasOrigin } from "@shared/workspace/protocol"; import type { GlyphCatalogAtlasPage } from "@/types/glyphCatalog"; import type { GlyphPreviewFrame } from "@/types/glyphPreview"; @@ -94,23 +94,13 @@ export class ResidentGlyphLayer { } } - async loadPages( - pages: readonly GlyphCatalogAtlasPage[], - signal: AbortSignal, - ): Promise { - if (pages.length === 0) { - return { horizontal: 0, minimumY: 0, maximumY: 0 }; - } + async loadPages(pages: readonly GlyphCatalogAtlasPage[], signal: AbortSignal): Promise { + if (pages.length === 0) return; const atlases: SlugAtlas[] = []; let preparedGeneration: number | null = null; let preparedOrigin: SlugAtlasOrigin | null = null; let atlas: SlugAtlas | null = null; - let previewExtents: SlugPreviewExtents = { - horizontal: 0, - minimumY: 0, - maximumY: 0, - }; try { for (const page of pages) { @@ -151,11 +141,9 @@ export class ResidentGlyphLayer { atlases.push(atlas); atlas = null; - previewExtents = mergePreviewExtents(previewExtents, descriptor.previewExtents); } this.#renderer.loadPages(atlases); - return previewExtents; } catch (error) { atlas?.destroy(); for (const uploadedAtlas of atlases) uploadedAtlas.destroy(); @@ -191,17 +179,6 @@ export class ResidentGlyphLayer { } } -function mergePreviewExtents( - current: SlugPreviewExtents, - next: SlugPreviewExtents, -): SlugPreviewExtents { - return { - horizontal: Math.max(current.horizontal, next.horizontal), - minimumY: Math.min(current.minimumY, next.minimumY), - maximumY: Math.max(current.maximumY, next.maximumY), - }; -} - function throwIfAborted(signal: AbortSignal): void { if (!signal.aborted) return; diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index c69d360b..6e6ef641 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -14,7 +14,7 @@ Renderer vector-path values and the accelerated marker-layer backend for editor - **Architecture Invariant:** Atlas invalidation never removes a presented root before all visible replacement pages are uploaded. Axis, source, mapping, directory, and structural changes retain the prior frame, prioritize every fixed page intersecting the viewport at the new authored revision, install that page set in one synchronous glyph-map replacement, and then replace offscreen pages. One bounded native or cached page may occupy the utility lane; no monolithic complete-font request blocks later visible work. -- **Architecture Invariant:** Preview scale remains metrics-derived. `SlugPreviewExtents` expands every cell from the font-wide all-source overflow without changing pixels per em; extents grow as pages arrive and stale extents remain safe during replacement. +- **Architecture Invariant:** Preview cell geometry is fixed. Axis and source changes redraw glyph contents without changing columns, row pitch, or cell dimensions. Each visible glyph unions its exact resolved bounds with the metrics-and-advance viewport, retains the metrics-derived default pixels per em when it fits, and otherwise scales down within its box. - **Architecture Invariant:** **CRITICAL**: The instance buffer layout (attribute offsets in the draw command) must exactly match the packing order in `MarkerHandleRenderer.#writeInstance`. If either side changes stride/offset, handles render garbage with no error. @@ -79,7 +79,7 @@ editor/rendering/markers/ `GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Directory revisions build one glyph-to-page index; invalidation and visible lookup never linearly search the directory per root. Initial residency selects every deterministic 256-root directory page intersecting the current viewport, uploads the complete set, and then `#refreshComplete` fills remaining pages while yielding between requests. `SlugRenderer.loadPages` constructs all GPU page resources before synchronously replacing glyph mappings, so a viewport crossing a page boundary cannot expose a mixed set. Local edits and global axis/source changes leave active mappings intact, abort stale candidates, and route through the same visible-first replacement. Scrolling during incomplete residency aborts background work and prioritizes newly visible pages. The invalid-root set is the residency authority: successful page installation removes its requested roots, complete residency means the set is empty, and the resident count is derived without rescanning GPU mappings. Once every root is current, the glyph canvas reports `data-fully-resident="true"`; `data-grid-readiness` distinguishes `Initial`, `Stale`, `Visible`, `Complete`, and `Unavailable` for product E2E assertions. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. -Every page reports all-source `SlugPreviewExtents`. The controller monotonically merges those bounds during the active generation, and `GlyphCatalogLayout` expands shared cell width, preview height, and row pitch using the existing metrics-derived pixels-per-em ratio. Oversized glyphs therefore retain the same scale rather than being individually fitted or clipped to the metrics box. +`GlyphCatalogLayout` owns fixed shared cell dimensions independent of atlas pages and design location. The preview shader reads each visible glyph's exact resolved scratch bounds, unions them with the metrics-and-advance viewport, and caps its fit scale at the metrics-derived `defaultPixelsPerEm`. Oversized glyphs shrink individually instead of clipping or resizing the Grid. ### Per-frame draw pipeline diff --git a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts index 316c15ea..2ef57e2f 100644 --- a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts +++ b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts @@ -114,6 +114,7 @@ export class SlugRenderer { pass.dispatchWorkgroups(packed.instanceCount); pass.end(); } + page.buffers.copyPreviewBounds(encoder, packed.instanceCount); { const pass = encoder.beginComputePass({ label: "shift Slug bands" }); pass.setPipeline(this.#pipelines.bands); diff --git a/apps/desktop/src/renderer/src/lib/slug/SlugRendererResources.ts b/apps/desktop/src/renderer/src/lib/slug/SlugRendererResources.ts index 0b43faa7..18d41a47 100644 --- a/apps/desktop/src/renderer/src/lib/slug/SlugRendererResources.ts +++ b/apps/desktop/src/renderer/src/lib/slug/SlugRendererResources.ts @@ -123,14 +123,24 @@ export class SlugRendererBuffers { 0, new Float32Array([ ...frame.style.color, - frame.style.viewHeight, - frame.style.fontTop, - frame.style.previewHeight, - frame.style.sideMargin, + frame.style.defaultPixelsPerEm, + frame.style.metricsTop, + frame.style.metricsBottom, + 0, ]), ); } + copyPreviewBounds(encoder: GPUCommandEncoder, glyphCount: number): void { + encoder.copyBufferToBuffer( + this.#scratch.bounds, + 0, + this.#scratch.previewBounds, + 0, + Math.max(1, glyphCount) * BOUNDS_BYTES, + ); + } + destroy(): void { this.#uniforms.globals.destroy(); this.#uniforms.variable.destroy(); @@ -250,6 +260,7 @@ export class SlugRendererBuffers { entries: [ { binding: 0, resource: { buffer: this.#scratch.advances } }, { binding: 1, resource: { buffer: this.#uniforms.preview } }, + { binding: 2, resource: { buffer: this.#scratch.previewBounds } }, ], }), ], @@ -292,7 +303,18 @@ function createScratch(device: GPUDevice, capacity: GlyphPreviewCapacity) { curves: storageBuffer(device, "shift Slug curves", capacity.curveCount * CURVE_BYTES), bands: storageBuffer(device, "shift Slug bands", capacity.bandCount * BAND_BYTES), indexes: storageBuffer(device, "shift Slug indexes", capacity.indexCount * INDEX_BYTES), - bounds: storageBuffer(device, "shift Slug bounds", capacity.glyphCount * BOUNDS_BYTES), + bounds: storageBuffer( + device, + "shift Slug bounds", + capacity.glyphCount * BOUNDS_BYTES, + GPUBufferUsage.COPY_SRC, + ), + previewBounds: storageBuffer( + device, + "shift Slug preview bounds", + capacity.glyphCount * BOUNDS_BYTES, + GPUBufferUsage.COPY_DST, + ), advances: storageBuffer(device, "shift Slug advances", capacity.glyphCount * ADVANCE_BYTES), componentTransforms: storageBuffer( device, @@ -307,6 +329,7 @@ function destroyScratch(scratch: ReturnType): void { scratch.bands.destroy(); scratch.indexes.destroy(); scratch.bounds.destroy(); + scratch.previewBounds.destroy(); scratch.advances.destroy(); scratch.componentTransforms.destroy(); } diff --git a/apps/desktop/src/renderer/src/types/glyphCatalog.ts b/apps/desktop/src/renderer/src/types/glyphCatalog.ts index e3f71564..3f7b0603 100644 --- a/apps/desktop/src/renderer/src/types/glyphCatalog.ts +++ b/apps/desktop/src/renderer/src/types/glyphCatalog.ts @@ -1,13 +1,6 @@ import type { GlyphCategory, GlyphCategorySummary } from "@shift/glyph-info"; import type { Rect2D } from "@shift/geo"; -import type { - Axis, - GlyphId, - GlyphName, - SlugPreviewExtents, - SourceId, - SourceMetrics, -} from "@shift/types"; +import type { Axis, GlyphId, GlyphName, SourceId, SourceMetrics } from "@shift/types"; import type { RefObject } from "react"; import type { ThemeName } from "./uiState"; import type { AxisLocation } from "./variation"; @@ -92,11 +85,6 @@ export interface GlyphCatalogControllerFrame { readonly editingGlyphId: GlyphId | null; } -/** Complete immutable Grid input presented with one shared preview extent. */ -export interface GridFrame extends GlyphCatalogControllerFrame { - readonly previewExtents: SlugPreviewExtents; -} - export interface GlyphNameInputProps { readonly glyph: GlyphCatalogItem; readonly onFinished: () => void; @@ -113,5 +101,4 @@ export interface GlyphCatalogCanvasProps { readonly openGlyph: (glyph: GlyphCatalogItem) => Promise; readonly onFirstFrame: () => void; readonly onUnavailable: () => void; - readonly onPreviewExtentsChange: (previewExtents: SlugPreviewExtents) => void; } diff --git a/apps/desktop/src/renderer/src/types/glyphPreview.ts b/apps/desktop/src/renderer/src/types/glyphPreview.ts index e12decf1..5326f91a 100644 --- a/apps/desktop/src/renderer/src/types/glyphPreview.ts +++ b/apps/desktop/src/renderer/src/types/glyphPreview.ts @@ -10,10 +10,9 @@ export interface GlyphPreviewInstance { /** Screen-space styling shared by resident glyph preview backends. */ export interface GlyphPreviewStyle { - readonly viewHeight: number; - readonly fontTop: number; - readonly previewHeight: number; - readonly sideMargin: number; + readonly defaultPixelsPerEm: number; + readonly metricsTop: number; + readonly metricsBottom: number; readonly color: readonly [number, number, number, number]; } diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index ec2143a8..bf9dcc94 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -52,7 +52,7 @@ crates/shift-bridge/ - `NapiNamedInstance` -- explicit product-preset DTO carrying stable identity and a complete external location. - `NapiGlyphProjection` -- compact location-independent glyph backing with reusable interpolation, exact-source exceptions, and Rust-owned `GlyphComponents` relationships. - `NapiSourceMetricsInterpolationSnapshot` -- metric schema, reusable interpolation basis, and ordered source values projected from native source-metric interpolation; derived state, never `.shift` authoring data. -- `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, scale-preserving `SlugPreviewExtents`, and aligned resident-section layout. +- `NapiSlugAtlas` -- small generation/page metadata, explicit authored root identities, exact-source selectors, deduplicated weight bases, cache-serialized preview extents, and aligned resident-section layout. - `authoredGlyphCompilation` -- diagnostic complete location-independent `AuthoredAtlas` prepared before device alignment and consumed by the complete-atlas endpoint. - `SlugAtlasGeneration` -- one aligned native atlas or page consumed by its stream API or released by its discard API. - `slugAtlasCacheRevision()` -- utility-only durable authored revision key; it does not make cached Slug bytes canonical workspace state. @@ -68,7 +68,7 @@ crates/shift-bridge/ 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. 8. `closeWorkspace()` drops the live Rust workspace handle. The utility process retains a clean package-backed SQLite document, but deletes untitled/imported documents and explicitly discarded dirty documents. 9. `exportWorkspace(request)` creates a `FontSaveSnapshot` and exports asynchronously through `shift-backends`. -10. The renderer calls `prepareSlugAtlasPage(glyphIds, alignment)` for deterministic fixed directory pages, prioritizing every page intersecting the current viewport. Every native miss independently acquires its indexed component closure and reports shared all-source preview extents. Each bounded build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. The utility may bypass native preparation with a validated external `CachedAtlas` page keyed by `slugAtlasCacheRevision()`, but cached and native pages share the same bounded renderer stream contract. New visible work supersedes queued complete-residency pages between calls. The complete preparation endpoints remain available to the external profiler; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. +10. The renderer calls `prepareSlugAtlasPage(glyphIds, alignment)` for deterministic fixed directory pages, prioritizing every page intersecting the current viewport. Every native miss independently acquires its indexed component closure. Each bounded build uses one compilation-scoped `GlyphProjectionSet`; no projection or resolved-source map survives its build. The utility may bypass native preparation with a validated external `CachedAtlas` page keyed by `slugAtlasCacheRevision()`, but cached and native pages share the same bounded renderer stream contract. New visible work supersedes queued complete-residency pages between calls. The complete preparation endpoints remain available to the external profiler; set `SHIFT_PROFILE_SLUG_ATLAS=1` for every nested native phase. ## Type Boundary diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index 7764dcaa..e876c424 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -9,7 +9,7 @@ GPU-independent preprocessing for the experimental Slug home/catalog glyph grid. - **No GPU ownership.** The crate produces deterministic CPU arrays and bytes shared by native `wgpu` benchmarks and Electron WebGPU. Device, queue, surface, and fallback policy belong to consumers. - **Checked ranges.** The reference implementation's unchecked 24-bit offset / 8-bit count packing is not used. Atlas offsets and counts are checked `u32` values; packed byte arithmetic is checked `usize`. - **Bands are location-bound.** The static builder bands one resolved shape. The variable path resolves and re-bands only visible glyphs after every weight update, so current-location membership stays exact without geometry upload. -- **Scale-preserving preview extents.** Every authored root page reports maximum all-source horizontal overhang and vertical bounds. The Grid may enlarge shared cells from those extents, but Slug never fits individual glyphs by changing pixels per em. +- **Bounds-fitted previews.** Grid cells stay fixed while each visible glyph unions its exact resolved bounds with the metrics-and-advance viewport. Normal glyphs retain the metrics-derived default pixels per em; only glyphs too large for the fixed preview box scale down. - **Deterministic topology conversion.** Lines become quadratics. Cubics use the conservative third-derivative error bound from Kurbo's `CubicBez::to_quads`, with a one-font-unit tolerance and equal parameter intervals. Compatible authored sources freeze the maximum subdivision count required by any source so variable topology remains identical. - **No shaping.** The grid addresses glyphs by dense atlas index and does not need a text shaper. - **Command ownership.** `OutlineCommand` is a Slug preprocessing input. No standalone packed-outline storage format exists. @@ -77,7 +77,7 @@ Warm p50 improved 5.41× (81.5%) with projection reuse. The subsequent acquisiti Representative warm native phases remain 23–25 ms projection preparation, about 0.5 ms weight-set collection, and 72–73 ms atlas addition. Within atlas addition, component preparation is about 19 ms, fallback bounds about 28.5 ms, and exact-source preparation about 7.3 ms. This exceeds the sub-second target without parallelism; parallel preparation remains unwarranted until a larger corpus demonstrates a new bottleneck. -The variable model keeps one base quadratic array plus base-relative `f32` source deltas. Each 8-byte source descriptor remains dense by default; only a source whose unchanged curves make sparse storage strictly smaller receives a tagged offset into a compact side table of sorted glyph-local indexes. Dense fonts therefore pay no sparse metadata tax. Each source references a global weight index so equal interpolation bases share a small per-frame weight vector. The complete packed atlas remains one logical byte stream but may span two `array` storage bindings. A split offset and every logical section offset fit in the existing 64-byte uniform; one accessor selects the physical buffer without changing packed bytes. Typed WGSL decoders preserve the exact little-endian resident layout while using exactly eight storage bindings in the resolve entry point, matching WebGPU's baseline binding-count and 128 MiB storage-binding limits. Compute preserves the full weighted-source equation as `base × sum(weights) + Σ(weight × delta)`, rather than assuming weights always sum to one. A one-bit-per-curve resident mask marks controls generated from authored lines: after endpoint interpolation, compute regenerates those controls with Slug's normalized perpendicular epsilon because that operation is nonlinear and cannot be represented exactly by source control deltas. One workgroup per visible glyph resolves curves and reduces exact current-location bounds into scratch; a second pass rebuilds the eight horizontal and vertical bands using those bounds. Fragment band selection reads the same scratch bounds. Cell sizing remains a consumer-owned metrics/advance transform, so neither loose all-location bounds nor current-location geometry can shrink or jump the grid layout. Offscreen glyphs perform none of this work until visible. +The variable model keeps one base quadratic array plus base-relative `f32` source deltas. Each 8-byte source descriptor remains dense by default; only a source whose unchanged curves make sparse storage strictly smaller receives a tagged offset into a compact side table of sorted glyph-local indexes. Dense fonts therefore pay no sparse metadata tax. Each source references a global weight index so equal interpolation bases share a small per-frame weight vector. The complete packed atlas remains one logical byte stream but may span two `array` storage bindings. A split offset and every logical section offset fit in the existing 64-byte uniform; one accessor selects the physical buffer without changing packed bytes. Typed WGSL decoders preserve the exact little-endian resident layout while using exactly eight storage bindings in the resolve entry point, matching WebGPU's baseline binding-count and 128 MiB storage-binding limits. Compute preserves the full weighted-source equation as `base × sum(weights) + Σ(weight × delta)`, rather than assuming weights always sum to one. A one-bit-per-curve resident mask marks controls generated from authored lines: after endpoint interpolation, compute regenerates those controls with Slug's normalized perpendicular epsilon because that operation is nonlinear and cannot be represented exactly by source control deltas. One workgroup per visible glyph resolves curves and reduces exact current-location bounds into scratch; a second pass rebuilds the eight horizontal and vertical bands using those bounds. Fragment band selection reads the same scratch bounds. Cell sizing remains consumer-owned and independent of authored or current-location bounds, so axis scrubbing cannot resize or reflow the grid. The preview vertex path uses exact current-location scratch bounds to fit each glyph within its fixed box, capped at the metrics-derived default pixels per em. Offscreen glyphs perform none of this work until visible. For 150 uniformly sampled Source Han glyphs, worst-case scratch reservation is bounded by the visible curves and `curve_count × 16` temporary band-index slots rather than all 65,535 glyphs. Component glyphs additionally reserve two 32-byte affine transforms per visible component occurrence; direct glyphs pay no component scratch. Full authored CJK import remains a subsequent model layer. diff --git a/crates/shift-slug/shaders/slug-variable.wgsl b/crates/shift-slug/shaders/slug-variable.wgsl index 6f850d65..a5a80c9e 100644 --- a/crates/shift-slug/shaders/slug-variable.wgsl +++ b/crates/shift-slug/shaders/slug-variable.wgsl @@ -28,7 +28,7 @@ struct VariableParams { struct PreviewParams { color: vec4, - // view height, font-space top, preview height, font-space side margin + // default pixels per em, metrics top, metrics bottom, padding geometry: vec4, }; @@ -136,6 +136,7 @@ struct VertexOutput { @group(2) @binding(5) var resolved_component_transforms: array; @group(3) @binding(0) var preview_resolved_advances: array; @group(3) @binding(1) var preview: PreviewParams; +@group(3) @binding(2) var preview_resolved_bounds: array>; var workgroup_curve_bounds: array, 64>; var workgroup_transform_start: u32; @@ -644,8 +645,9 @@ fn vertex_variable( return output; } -// Advance-fitted preview path. Consumers provide the content rectangle and -// layout values; this shader owns no row, cell, gap, or padding policy. +// Bounds-fitted preview path. Consumers provide a fixed content rectangle; +// each resolved glyph keeps the default scale until its complete view needs +// to shrink to fit that rectangle. @vertex fn vertex_variable_preview( @builtin(vertex_index) vertex_index: u32, @@ -653,18 +655,15 @@ fn vertex_variable_preview( ) -> VertexOutput { let instance = instances[instance_index]; let content_size = max(instance.pixel_rect.zw - instance.pixel_rect.xy, vec2(1.0)); - let view_height = max(preview.geometry.x, 1.0); + let glyph_bounds = preview_resolved_bounds[instance_index]; let advance = preview_resolved_advances[instance_index]; - let side_margin = preview.geometry.w; - let view_width = max(advance + 2.0 * side_margin, 1.0); - let preview_height = max(preview.geometry.z, 1.0); - let requested_size = vec2( - max(preview_height, preview_height * view_width / view_height), - preview_height, - ); - let preview_size = min(content_size, requested_size); - let pixels_per_em = min(preview_size.x / view_width, preview_size.y / view_height); - let render_size = vec2(view_width, view_height) * pixels_per_em; + let view_min = min(vec2(0.0, preview.geometry.z), glyph_bounds.xy); + let view_max = max(vec2(advance, preview.geometry.y), glyph_bounds.zw); + let view_size = max(view_max - view_min, vec2(1.0)); + let fit_pixels_per_em = min(content_size.x / view_size.x, content_size.y / view_size.y); + let default_pixels_per_em = max(preview.geometry.x, 1.0 / 65536.0); + let pixels_per_em = min(default_pixels_per_em, fit_pixels_per_em); + let render_size = view_size * pixels_per_em; let render_min = (instance.pixel_rect.xy + instance.pixel_rect.zw - render_size) * 0.5; let render_max = render_min + render_size; let pixel_position = mix(render_min, render_max, quad_coordinate(vertex_index)); @@ -678,10 +677,7 @@ fn vertex_variable_preview( var output: VertexOutput; output.position = vec4(clip_position, 0.0, 1.0); output.em_scale = em_scale; - output.em_offset = vec2( - -side_margin - render_min.x * em_scale.x, - preview.geometry.y - render_min.y * em_scale.y, - ); + output.em_offset = vec2(view_min.x, view_max.y) - render_min * em_scale; output.instance_index = instance_index; return output; } From 583625d4d2e918257ba16366f31ce21c0a4d3efc Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 2 Aug 2026 14:28:40 -0400 Subject: [PATCH 8/8] Fix retried Grid cache publication --- apps/desktop/e2e/README.md | 4 +- .../e2e/{gpu.spec.ts => glyph-grid.spec.ts} | 54 +++++++++---------- apps/desktop/playwright.config.ts | 4 +- apps/desktop/src/main/docs/DOCS.md | 2 +- .../src/utility/workspace/CachedAtlas.ts | 24 ++++----- .../utility/workspace/WorkspaceHost.test.ts | 49 +++++++++++++++++ 6 files changed, 89 insertions(+), 48 deletions(-) rename apps/desktop/e2e/{gpu.spec.ts => glyph-grid.spec.ts} (93%) diff --git a/apps/desktop/e2e/README.md b/apps/desktop/e2e/README.md index 98fe5650..20a0a958 100644 --- a/apps/desktop/e2e/README.md +++ b/apps/desktop/e2e/README.md @@ -18,7 +18,7 @@ Append a Playwright file filter for a focused run: ```sh pnpm test:e2e:visual e2e/home.spec.ts -pnpm test:e2e:gpu e2e/gpu.spec.ts +pnpm test:e2e:gpu e2e/glyph-grid.spec.ts ``` Build the native bridge first with `pnpm build:native` when its binary is absent or stale. Each E2E command builds the Electron main, workspace, preload, and renderer bundles through `e2e/build.ts`. @@ -34,7 +34,7 @@ Build the native bridge first with `pnpm build:native` when its binary is absent Visual tests default to MutatorSans. The GPU and performance fixture also defaults to MutatorSans, but accepts a real font or designspace through `SHIFT_E2E_FONT_PATH`: ```sh -SHIFT_E2E_FONT_PATH=/path/to/font.ttf pnpm test:e2e:gpu e2e/gpu.spec.ts +SHIFT_E2E_FONT_PATH=/path/to/font.ttf pnpm test:e2e:gpu e2e/glyph-grid.spec.ts ``` Fixtures copy source files into a temporary workspace. Tests must not depend on a developer's existing Shift workspace or user-data directory. diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/glyph-grid.spec.ts similarity index 93% rename from apps/desktop/e2e/gpu.spec.ts rename to apps/desktop/e2e/glyph-grid.spec.ts index 10e4039d..d6bae97f 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/glyph-grid.spec.ts @@ -4,7 +4,7 @@ import { test, expect, navigateToEditor } from "./fixtures/perfApp"; const RESIDENT_GPU_ERROR = /resident glyph (device lost|frame failed|initialization failed)/i; -test.describe("Resident catalog GPU", () => { +test.describe("Resident Glyph Grid", () => { test("redraws the resident viewport without rebuilding it after editor navigation", async ({ page, }) => { @@ -20,7 +20,11 @@ test.describe("Resident catalog GPU", () => { const scrollViewport = page.getByLabel("Glyph catalog"); await scrollViewport.waitFor({ state: "visible" }); const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); - await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + await page.evaluate(() => document.fonts.ready); + await afterNextPaint(page); const initialSize = await glyphCanvas.evaluate((canvas) => ({ width: canvas.width, @@ -48,8 +52,8 @@ test.describe("Resident catalog GPU", () => { await scrollViewport.click({ position: { x: 50, y: 50 } }); await page.waitForURL(/#\/editor\//); + await expect(page.locator("#scene-canvas")).toBeVisible(); await afterNextPaint(page); - await page.waitForTimeout(3_000); await expect .poll(() => @@ -237,9 +241,11 @@ test.describe("Resident catalog GPU", () => { ); expect(recoveryDuration).toBeLessThan(1_000); expect(atlasLoads.complete).toBe(0); - expect(atlasLoads.patches).toHaveLength(1); - expect(atlasLoads.patches[0]).toBeGreaterThan(0); - expect(atlasLoads.patches[0]).toBeLessThan(atlasLoads.glyphCount); + expect(atlasLoads.patches.length).toBeGreaterThanOrEqual(1); + for (const rootCount of atlasLoads.patches) { + expect(rootCount).toBeGreaterThan(0); + expect(rootCount).toBeLessThanOrEqual(atlasLoads.glyphCount); + } }); test("keeps distant glyphs resident after a topology patch", async ({ electronApp, page }) => { @@ -300,17 +306,13 @@ test.describe("Resident catalog GPU", () => { expect(scrollDuration).toBeLessThan(1_000); }); - test("replaces the visible frame before completing a selected-source deletion", async ({ - electronApp, - page, - }) => { + test("replaces a selected-source deletion atomically", async ({ electronApp, page }) => { const glyphCanvas = await preparePagedGrid(electronApp, page); const variable = await createVariableDesignspace(page); await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { timeout: 30_000, }); await trackGridTransitions(page); - await trackSlugAtlasLoads(page); await page.evaluate(async ({ axisId, sourceId }) => { const workspace = window.shift; @@ -325,14 +327,8 @@ test.describe("Resident catalog GPU", () => { timeout: 30_000, }); const state = await observedGridState(page); - expect(state.readiness).toEqual(expect.arrayContaining(["Stale", "Visible", "Complete"])); - expect(state.readiness.indexOf("Stale")).toBeLessThan(state.readiness.indexOf("Visible")); - expect(state.readiness.indexOf("Visible")).toBeLessThan( - state.readiness.lastIndexOf("Complete"), - ); + expectAtomicGridReadiness(state.readiness); expect(state.hiddenTransitions).toBe(0); - expect(state.patchRootCounts[0]).toBeLessThan(state.glyphCount); - expect(state.patchRootCounts.at(-1)).toBe(state.glyphCount); }); test("replaces a non-default design location atomically after deleting its axis", async ({ @@ -367,11 +363,7 @@ test.describe("Resident catalog GPU", () => { ), ).toBe(false); const state = await observedGridState(page); - expect(state.readiness).toEqual(expect.arrayContaining(["Stale", "Visible", "Complete"])); - expect(state.readiness.indexOf("Stale")).toBeLessThan(state.readiness.indexOf("Visible")); - expect(state.readiness.indexOf("Visible")).toBeLessThan( - state.readiness.lastIndexOf("Complete"), - ); + expectAtomicGridReadiness(state.readiness); expect(state.hiddenTransitions).toBe(0); }); @@ -545,21 +537,25 @@ async function trackGridTransitions(page: Page): Promise { async function observedGridState(page: Page): Promise<{ readiness: string[]; hiddenTransitions: number; - patchRootCounts: number[]; - glyphCount: number; }> { return page.evaluate(() => ({ readiness: JSON.parse( document.documentElement.dataset.gridReadinessTransitions ?? "[]", ) as string[], hiddenTransitions: Number(document.documentElement.dataset.gridHiddenTransitions), - patchRootCounts: JSON.parse( - document.documentElement.dataset.slugPatchRootCounts ?? "[]", - ) as number[], - glyphCount: window.shift?.font.glyphRecords().length ?? 0, })); } +function expectAtomicGridReadiness(readiness: readonly string[]): void { + const staleIndex = readiness.indexOf("Stale"); + const visibleIndex = readiness.indexOf("Visible", staleIndex); + const completeIndex = readiness.lastIndexOf("Complete"); + + expect(staleIndex).toBeGreaterThanOrEqual(0); + expect(completeIndex).toBeGreaterThan(staleIndex); + if (visibleIndex >= 0) expect(visibleIndex).toBeLessThan(completeIndex); +} + async function trackSlugFrameSubmits(page: Page): Promise { await page.evaluate(() => { const originalSubmit = GPUQueue.prototype.submit; diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index c52ad8aa..790bbb76 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -30,11 +30,11 @@ export default defineConfig({ projects: [ { name: "visual", - testIgnore: /(?:gpu|perf)\.spec/, + testIgnore: /(?:glyph-grid|gpu|perf)\.spec/, }, { name: "gpu", - testMatch: /gpu\.spec/, + testMatch: /glyph-grid\.spec/, }, { name: "perf", diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 6c09921d..53d69722 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -11,7 +11,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. - **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty documents are pruned. - **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. -- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. +- **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Staging paths use readable `run-{pid}-{id}/page-{index}-{id}.zst` names, and every retry owns a distinct file until publication. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. ## Codemap diff --git a/apps/desktop/src/utility/workspace/CachedAtlas.ts b/apps/desktop/src/utility/workspace/CachedAtlas.ts index b63b5dd0..21180eaa 100644 --- a/apps/desktop/src/utility/workspace/CachedAtlas.ts +++ b/apps/desktop/src/utility/workspace/CachedAtlas.ts @@ -17,7 +17,6 @@ import { z } from "zod"; import type { CachedAtlas, CachedAtlasFile, - CachedAtlasKey, CachedAtlasPage, CachedAtlasPageRequest, CachedAtlasPageSink, @@ -36,7 +35,7 @@ const INDEX_CHECKSUM_BYTES = 32; const INDEX_CHECKSUM_OFFSET = MAGIC.byteLength + 4; const HEADER_BYTES = INDEX_CHECKSUM_OFFSET + INDEX_CHECKSUM_BYTES; const MAXIMUM_INDEX_BYTES = 64 * 1024 * 1024; -const STAGING_SESSION = `${process.pid}-${crypto.randomUUID()}`; +const STAGING_SESSION = `run-${process.pid}-${shortId()}`; const closedCachedAtlases = new WeakSet(); let lastTouchMilliseconds = 0; @@ -150,8 +149,8 @@ export function stageCachedAtlasPage( descriptor: SlugAtlas, ): CachedAtlasPageSink { validatePageRequest(request); - const filePath = stagedPagePath(rootPath, request.key, request.pageIndex); - const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`; + const filePath = stagedPagePath(rootPath, request.pageIndex); + const temporaryPath = `${filePath}.tmp`; fs.mkdirSync(path.dirname(filePath), { recursive: true }); const compressor = createZstdCompress(); @@ -358,7 +357,7 @@ export async function publishCachedAtlas( throw new Error("cached atlas index exceeds the supported size"); } - const temporaryPath = `${targetPath}.${crypto.randomUUID()}.tmp`; + const temporaryPath = `${targetPath}.${shortId()}.tmp`; fs.mkdirSync(path.dirname(targetPath), { recursive: true }); const output = await fs.promises.open(temporaryPath, "wx"); @@ -741,15 +740,12 @@ function cachedAtlasPath(rootPath: string, documentKey: string): string { return path.join(rootPath, `${hashKey(documentKey)}.atlas`); } -function stagedPagePath(rootPath: string, key: CachedAtlasKey, pageIndex: number): string { - return path.join( - rootPath, - "staging", - STAGING_SESSION, - hashKey(key.documentKey), - hashKey(key.revisionKey), - `${pageIndex}.zst`, - ); +function stagedPagePath(rootPath: string, pageIndex: number): string { + return path.join(rootPath, "staging", STAGING_SESSION, `page-${pageIndex}-${shortId()}.zst`); +} + +function shortId(): string { + return crypto.randomBytes(4).toString("hex"); } function hashKey(value: string): string { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index f17409a9..2196dd22 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -315,6 +315,55 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { await shell.call("workspace.close", { discard: true }); }); + it("publishes cached pages after retrying one page before the build completes", async () => { + const sync = await connectSyncLane(); + const snapshot = await createWorkspace(sync); + const first = createGlyphALayer(snapshot.sources[0]!.id); + const secondGlyphId = mintGlyphId(); + const secondLayerId = mintLayerId(); + await applyWorkspace(sync, { + intents: [ + ...first.intents, + createGlyph("B" as GlyphName, 66 as Unicode, secondGlyphId), + createGlyphLayer(secondGlyphId, snapshot.sources[0]!.id, secondLayerId), + ], + }); + + const firstRequest = { + glyphIds: [first.glyphId], + alignment: 256, + pageIndex: 0, + pageCount: 2, + replacementPageIndices: [0, 1], + }; + const secondRequest = { + ...firstRequest, + glyphIds: [secondGlyphId], + pageIndex: 1, + }; + const firstPage = await sync.call("workspace.slugAtlasPagePrepare", firstRequest); + await streamSlugAtlas(sync, firstPage.generation, 64, firstPage.origin); + const retriedPage = await sync.call("workspace.slugAtlasPagePrepare", firstRequest); + const retriedBytes = await streamSlugAtlas( + sync, + retriedPage.generation, + 64, + retriedPage.origin, + ); + const secondPage = await sync.call("workspace.slugAtlasPagePrepare", secondRequest); + await streamSlugAtlas(sync, secondPage.generation, 64, secondPage.origin); + const cachedPage = await sync.call("workspace.slugAtlasPagePrepare", firstRequest); + const cachedBytes = await streamSlugAtlas(sync, cachedPage.generation, 64, cachedPage.origin); + + expect(firstPage.origin).toBe("native"); + expect(retriedPage.origin).toBe("native"); + expect(secondPage.origin).toBe("native"); + expect(cachedPage.origin).toBe("cached"); + expect(cachedBytes).toEqual(retriedBytes); + + await shell.call("workspace.close", { discard: true }); + }); + it("cancels native Slug production when the renderer rejects a chunk", async () => { const sync = await connectSyncLane(); const snapshot = await createWorkspace(sync);