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 56% rename from apps/desktop/e2e/gpu.spec.ts rename to apps/desktop/e2e/glyph-grid.spec.ts index 9c921b95..d6bae97f 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/glyph-grid.spec.ts @@ -1,9 +1,10 @@ -import type { Page } from "@playwright/test"; +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; -test.describe("Resident catalog GPU", () => { +test.describe("Resident Glyph Grid", () => { test("redraws the resident viewport without rebuilding it after editor navigation", async ({ page, }) => { @@ -19,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, @@ -47,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(() => @@ -217,10 +222,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; @@ -235,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 }) => { @@ -286,10 +294,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; @@ -297,47 +306,256 @@ 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 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 page.evaluate(async ({ axisId, sourceId }) => { + const workspace = window.shift; + 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, + }); + const state = await observedGridState(page); + expectAtomicGridReadiness(state.readiness); + expect(state.hiddenTransitions).toBe(0); + }); + + test("replaces a non-default design location atomically after deleting its axis", 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); + + const deletedAxis = await page.evaluate(async ({ axisId }) => { + const workspace = window.shift; + if (!workspace) throw new Error("Expected workspace"); + + workspace.editor.setDesignLocation(new Map([[axisId, 750]])); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + workspace.font.deleteAxis(axisId); + await workspace.font.editCoordinator.settled(); + return axisId; + }, variable); + + 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); + expectAtomicGridReadiness(state.readiness); + expect(state.hiddenTransitions).toBe(0); + }); + test("fits oversized outlines without resizing cells while scrubbing an axis", 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 }); + const catalogSurface = scrollViewport.locator(".."); + const glyphCanvas = catalogSurface.locator("canvas").first(); + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); + 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 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; + await expect(glyphCanvas).toHaveAttribute("data-grid-readiness", "Complete", { + timeout: 30_000, + }); - 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: "[]" }); + 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(); + 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); }); }); +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(); + 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 }; + }); +} + +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; +}> { + return page.evaluate(() => ({ + readiness: JSON.parse( + document.documentElement.dataset.gridReadinessTransitions ?? "[]", + ) as string[], + hiddenTransitions: Number(document.documentElement.dataset.gridHiddenTransitions), + })); +} + +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; @@ -371,13 +589,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/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, 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 55b3eefa..53d69722 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; 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 @@ -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. 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 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/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..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", }); @@ -124,18 +125,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/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 2751e7d7..6817ccd7 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -11,13 +11,17 @@ 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, + 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; + +/** Owns catalog DOM events, visible-first atlas replacement, and frame scheduling. */ export class GlyphCatalogController { readonly #container: HTMLDivElement; readonly #glyphCanvas: HTMLCanvasElement; @@ -32,11 +36,17 @@ export class GlyphCatalogController { readonly #resizeObserver: ResizeObserver; readonly #fontEffect: Effect; readonly #invalidGlyphIds = new Set(); + readonly #replacementPageIndices = new Set(); + readonly #pageIndexByGlyph = new Map(); - #frame: GlyphCatalogControllerFrame | null = null; + #targetFrame: GlyphCatalogControllerFrame | null = null; + #activeFrame: GlyphCatalogControllerFrame | 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; @@ -64,12 +74,17 @@ export class GlyphCatalogController { this.#onUnavailable = onUnavailable; 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 +101,22 @@ export class GlyphCatalogController { } update(frame: GlyphCatalogControllerFrame, inputContainer: HTMLDivElement | null): void { - const previous = this.#frame; + const previousTarget = this.#targetFrame; + this.#targetFrame = frame; + 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 +130,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 +139,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 +155,46 @@ 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 (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 (this.#frame?.active) this.#startLayer(); - return; + const invalidateAll = glyphIds === null || directoryChanged; + if (invalidateAll) { + this.#invalidGlyphIds.clear(); + for (const glyphId of fontGlyphIds) this.#invalidGlyphIds.add(glyphId); + } else { + for (const glyphId of glyphIds) { + if (this.#pageIndexByGlyph.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.#replacementPageIndices.clear(); + 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.#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 +219,220 @@ 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 (this.#completeBuild) { + this.#completeBuild.abort(new Error("visible Grid frame takes priority")); + return; } - if (missingGlyphIds.size === 0) { + + 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; - for (const glyphId of glyphIds) this.#invalidGlyphIds.delete(glyphId); - this.#updateFullyResident(); - this.#refresh = null; + const pageRequests = this.#pageRequests(glyphIds); + await layer.loadPages(pageRequests, visibleBuild.signal); + if (this.#disposed || this.#visibleBuild !== visibleBuild || visibleBuild.signal.aborted) { + return; + } + + const latestTarget = this.#targetFrame; + if (!latestTarget) return; + this.#activeFrame = latestTarget; + for (const request of pageRequests) { + for (const glyphId of request.glyphIds) this.#invalidGlyphIds.delete(glyphId); + } 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 pageIndex = start / ATLAS_PAGE_ROOT_COUNT; + await this.#layer.loadPages([this.#pageRequest(pageIndex)], completeBuild.signal); + if (completeBuild.signal.aborted) break; + + for (const glyphId of pageGlyphIds) this.#invalidGlyphIds.delete(glyphId); + 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(); + } + + #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 { + return this.#pageIndexByGlyph.get(glyphId) ?? null; + } + + #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); 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.#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; this.#needsRedraw = true; this.#onReadyChange(false); this.#onUnavailable(); } - #layout(): GlyphCatalogLayout { + #layout(frame = this.#activeFrame ?? this.#targetFrame): GlyphCatalogLayout { return new GlyphCatalogLayout( this.#container.clientWidth, this.#container.clientHeight, - this.#frame?.glyphs.length ?? 0, + frame?.glyphs.length ?? 0, ); } - #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 +443,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 +456,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,16 +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); + 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), + defaultPixelsPerEm: (frame.layout.previewHeight * ratio) / Math.max(1, viewHeight), + metricsTop, + metricsBottom: metricsTop - viewHeight, color: parseCssColor(getComputedStyle(this.#container).color), }, viewportWidth: this.#glyphCanvas.width, @@ -360,49 +502,60 @@ 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 = - Boolean(layer) && - glyphIds.every((glyphId) => !this.#invalidGlyphIds.has(glyphId)) && - Boolean(layer?.hasGlyphs(glyphIds)); - this.#glyphCanvas.dataset.fullyResident = String(fullyResident); + 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(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); + + 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 +566,7 @@ export class GlyphCatalogController { #handleScroll = (): void => { this.#needsRedraw = true; + void this.#refreshVisible(); this.redraw(); }; @@ -431,17 +585,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 +625,7 @@ export class GlyphCatalogController { } } } + +function sameGlyphIds(left: readonly GlyphId[], right: readonly GlyphId[]): boolean { + return left.length === right.length && left.every((glyphId, index) => glyphId === right[index]); +} 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..1c2ed388 100644 --- a/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts +++ b/apps/desktop/src/renderer/src/components/home/glyphCatalogLayout.test.ts @@ -12,36 +12,40 @@ function catalog(count: number): GlyphCatalogItem[] { })); } +function layout(width: number, height: number, glyphCount: number): GlyphCatalogLayout { + return new GlyphCatalogLayout(width, height, glyphCount); +} + 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 +53,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 +63,24 @@ 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); + const result = layout(280, 200, glyphs.length); + const frame = result.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(); + 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); } }); }); 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..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,6 @@ 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"; 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; @@ -40,20 +42,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 +57,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 +76,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,64 +88,70 @@ 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 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; 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; } - const loadedAtlas = atlas; - atlas = null; - this.#renderer.loadPage(loadedAtlas); + this.#renderer.loadPages(atlases); } 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; 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..6e6ef641 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 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 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 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. @@ -28,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: @@ -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 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. @@ -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`. 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. + +`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 93df34fe..2ef57e2f 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); } @@ -93,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/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 89dbb027..3f7b0603 100644 --- a/apps/desktop/src/renderer/src/types/glyphCatalog.ts +++ b/apps/desktop/src/renderer/src/types/glyphCatalog.ts @@ -63,7 +63,17 @@ 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"; + +/** 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[]; readonly location: AxisLocation; 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/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 a2972caf..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. */ @@ -128,7 +145,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 }; @@ -208,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": { @@ -220,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. */ @@ -230,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..b40a064a --- /dev/null +++ b/apps/desktop/src/utility/workspace/CachedAtlas.test.ts @@ -0,0 +1,291 @@ +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 { + closeCachedAtlas, + loadCachedAtlasPage, + 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 request = pageRequest(key, 1, [glyphB], [0, 1]); + const opened = await openCachedAtlas(rootPath, request); + if (!opened) throw new Error("expected CachedAtlas to open"); + + 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 () => { + 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 bytesAfterCorruption = await readPage(pageRequest(key, 0, [glyphA], [0])); + + expect(bytesAfterCorruption).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 readPage(pageRequest(firstKey, 0, [glyphA], [0])); + + await pruneCachedAtlases(rootPath, budget); + + 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 () => { + 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 readPage(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 readPage(pageRequest(oldKey, 0, [glyphA], [0]))).toBeNull(); + expect(publishedFiles()).toHaveLength(1); + 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, + 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..21180eaa --- /dev/null +++ b/apps/desktop/src/utility/workspace/CachedAtlas.ts @@ -0,0 +1,769 @@ +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"; +import { createZstdCompress, createZstdDecompress } from "node:zlib"; +import type { + GlyphId, + InterpolationBasis, + SlugAtlas, + SlugGlyph, + SlugWeightSet, +} from "@shift/types"; +import { z } from "zod"; +import type { + CachedAtlas, + CachedAtlasFile, + CachedAtlasPage, + CachedAtlasPageRequest, + CachedAtlasPageSink, + CachedAtlasPublication, + CachedSlugAtlas, + OpenedCachedAtlas, + 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 = `run-${process.pid}-${shortId()}`; +const closedCachedAtlases = new WeakSet(); +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.pageIndex); + const temporaryPath = `${filePath}.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 latest cache artifact while parsing its index exactly once. */ +export async function openCachedAtlas( + rootPath: string, + request: CachedAtlasPageRequest, +): Promise { + validatePageRequest(request); + const filePath = cachedAtlasPath(rootPath, request.key.documentKey); + let file: FileHandle | null = null; + + try { + 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 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 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 closeCachedAtlas(opened).catch(() => {}); + await removeCachedAtlas(opened.filePath); + return null; + } + + const compressed = fs.createReadStream(opened.filePath, { + fd: opened.file.fd, + autoClose: false, + start: compressedOffset, + end: compressedOffset + page.compressedLength - 1, + }); + const decompressed = compressed.pipe(createZstdDecompress()); + return { + atlas: page.atlas, + stream: Readable.toWeb(decompressed) as OpenedCachedAtlasPage["stream"], + }; + } catch { + 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, + 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}.${shortId()}.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 { + return await readCachedAtlasFile(file); + } finally { + await file.close(); + } +} + +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 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(file, cached, HEADER_BYTES + indexLength); + return cached; +} + +async function validateCachedAtlas( + file: FileHandle, + 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 file.stat(); + 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 { + 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); + } +} + +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 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, + })) { + 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, 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 { + 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 3e955256..2196dd22 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]); } @@ -179,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, @@ -241,7 +258,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); @@ -251,7 +267,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 through one validated cached artifact", async () => { const sync = await connectSyncLane(); const snapshot = await createWorkspace(sync); const first = createGlyphALayer(snapshot.sources[0]!.id); @@ -265,14 +281,87 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { ], }); - const page = await sync.call("workspace.slugAtlasPagePrepare", { + const request = { glyphIds: [secondGlyphId], alignment: 256, + 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); + 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 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 }); + }); + + 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 bytes = await streamSlugAtlas(sync, page.generation, 64, true); - expect(bytes.byteLength).toBe(page.layout.totalLength); - expect(page.glyphs.map((entry) => entry.glyphId)).toEqual([secondGlyphId]); + 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 () => { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 2c691775..50a37dcb 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,33 @@ import type { WorkspaceExportResult, WorkspaceGlyphSnapshot, WorkspacePackageIdentity, + WorkspaceSlugAtlas, + WorkspaceSlugAtlasPageRequest, WorkspaceSnapshot, } from "../../shared/workspace/protocol"; import { PortByteStream } from "../../shared/workspace/PortByteStream"; +import { + closeCachedAtlas, + DEFAULT_ATLAS_CACHE_BYTE_BUDGET, + loadCachedAtlasPage, + 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 OpenedCachedAtlas, + type OpenedCachedAtlasPage, + type PreparedAtlasPage, + type StagedCachedAtlasPage, +} from "./types"; /** * Construction options for {@link WorkspaceHost}. @@ -28,6 +51,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 +71,29 @@ 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; + #atlasCacheRevision: string | 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(); 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; } @@ -69,8 +105,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); @@ -97,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 }; }), @@ -124,27 +161,93 @@ 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.#currentAtlasCacheRevision(), + }, + }; + const cached = await this.#loadCachedAtlasPage(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 #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); + 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; + 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, @@ -155,7 +258,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(); @@ -164,6 +271,7 @@ export class WorkspaceHost { async #streamSlugAtlasPage( generation: number, + origin: SlugAtlasOrigin, maximumLength: number, ports: readonly unknown[], ): Promise { @@ -172,20 +280,157 @@ 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); + 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 this.#closeOpenedCachedAtlas(); + this.#openedCachedAtlasKey = null; + 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(); this.#bridge.createUntitledWorkspace(document.storePath); this.#bridge.setDocumentId(document.documentId); this.#documentId = document.documentId; + this.#atlasCacheRevision = null; this.#packageAddress = null; return this.#emitDocumentChanged(); @@ -228,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(); @@ -246,6 +492,7 @@ export class WorkspaceHost { #adoptDocument(document: DocumentAllocation, address: PackageAddress | null): void { this.#documentId = document.documentId; + this.#atlasCacheRevision = null; this.#packageAddress = address; } @@ -284,7 +531,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) { @@ -294,8 +541,21 @@ 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(); this.#documentId = null; + this.#atlasCacheRevision = null; this.#packageAddress = null; if (!address || discard) { @@ -344,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"); @@ -353,6 +618,34 @@ 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, + 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..0b9dfc97 100644 --- a/apps/desktop/src/utility/workspace/types.ts +++ b/apps/desktop/src/utility/workspace/types.ts @@ -1,4 +1,105 @@ -import type { WorkspacePackageIdentity } from "../../shared/workspace/protocol"; +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. */ +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; +}; + +/** 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; + 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 236d48e1..bf9dcc94 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. `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 @@ -52,9 +52,10 @@ 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, 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. ## 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. 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)` 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-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 9efa1951..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. * @@ -157,6 +159,7 @@ export interface NapiSlugAtlas { bandCount: number weightCount: number layout: NapiSlugLayout + previewExtents: NapiSlugPreviewExtents glyphs: Array weightSets: Array atlasGlyphCount: number @@ -191,6 +194,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..131eb945 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) @@ -953,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 1a714fe7..e876c424 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. +- **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. @@ -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 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. @@ -76,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; } 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/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 545ff4bb..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. * @@ -172,6 +174,7 @@ export interface SlugAtlas { bandCount: number weightCount: number layout: SlugLayout + previewExtents: SlugPreviewExtents glyphs: Array weightSets: Array atlasGlyphCount: number @@ -206,6 +209,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,