diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5009a07..6151a454d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,16 @@ jobs: # include path, or a conformance error in the embedded layer — is a # compile failure here, not a silent WebViewNotFound at runtime. - run: zig build test-webview-system-link -Dplatform=windows + # The registered-font receipt, natively on Windows: runs the + # font-registry suite — registration validation, the glyph-budget + # gate, present/reference pixel parity, and the Chinese-receipt + # test that registers the committed subsetted Noto Sans SC through + # the app-fonts seam and proves the rendered string is real glyphs, + # not tofu — on real Windows, not under emulation. The font + # pipeline is platform-neutral Zig, so this lane pins that neutral + # truth on a Windows host (the Linux run lives in the Zig Core + # lane's `zig build test`). + - run: zig build test-canvas-fonts cef-platform-tooling: name: CEF Platform Tooling diff --git a/build.zig b/build.zig index 84ab27dd8..f74d5dcb8 100644 --- a/build.zig +++ b/build.zig @@ -809,6 +809,100 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(base)" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(NativeSdkWeightedSansFont(@[ @\"Geist-Bold\", @\"Geist Bold\" ], base, NSFontWeightBold, YES, size))" }, }); + addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit host invalidates BOTH per-process registered-font caches at registration (the caches are per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face or its measured widths): the NSFont size cache by prefix eviction and the measured-width NSCache by a process-global registration token in its key", &.{ + // No SDK test tier links appkit_host.m (only managed app builds + // compile it), so the eviction wiring is pinned textually like + // the other AppKit host contracts: the shared accessor both the + // resolve and registration paths use, the prefix eviction inside + // register_font, and the honest lifetime comment. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSString *stalePrefix = [NSString stringWithFormat:@\"%llu/\", (unsigned long long)font_id];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "an id is only permanent within" }, + // The width-cache half: NSCache cannot enumerate keys, so the + // measured-width cache is invalidated by a registration token + // drawn from one process-global monotonic counter (tokens never + // repeat, which is what lets unregister DELETE an id's record + // instead of retaining a bumped one per retired id) — the token + // table, the fresh stamp inside register_font, and the + // token-carrying key inside measure_text. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary *NativeSdkRegisteredFontTokens(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = ++NativeSdkRegisteredFontTokenCounter;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRegisteredFontTokens()[@(font_id)] = @(token);" }, + // The stamp is also the registration's OWNERSHIP token: register + // reports it to the caller (`*out_token`), the runtime stores it + // beside the captured unregister owner, and unregister removes + // the id's state only while the id's current registration still + // carries it — an older runtime's deinit must never tear down a + // newer runtime's live face under a shared id (ids are + // per-runtime, host font state is per-process, last wins). + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "*out_token = token;" }, + // The token and the registered face reach measure_text from ONE + // critical section (the snapshot helper): separate acquisitions + // let a registration land between the token read and the face + // resolution, pairing token 0 with the new registered face and + // caching registered widths under the reusable token-0 key — + // stale registered widths served after teardown. The snapshot + // signature and its measure_text call site are pinned so the + // two-acquisition shape cannot quietly return. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSFont *NativeSdkRegisteredFontSnapshot(unsigned long long value, CGFloat size, unsigned long long *out_token)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped);" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" }, + // The width-cache WRITE is token-rechecked. Shaping runs outside + // the guard, so an unregister can land inside the shaping window: + // it clears the whole width cache (its only possible eviction), + // and an unconditional post-shape write would then repopulate the + // cleared cache with a retired-token entry — unreachable for + // serving (tokens never repeat) but resident until memory + // pressure, breaking the zero-retained-state teardown the clear + // exists to guarantee. measure_text therefore re-enters the + // descriptor guard after shaping and writes only while the id's + // current token still equals the snapshotted one; token 0 + // (built-in resolution — never registered, so never cleared) + // caches without the recheck. Both branches are pinned so the + // unconditional-write shape cannot quietly return. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (token == 0) {" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSNumber *currentToken = NativeSdkRegisteredFontTokens()[@(font_id)];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (currentToken && currentToken.unsignedLongLongValue == token) {" }, + // The teardown half: Runtime.deinit returns the host-side + // registration through the unregister owner each font entry + // captured at registration time (never live `options.platform`, + // which is publicly mutable), and the ObjC removal drops the + // descriptor, the size-cache entries, AND the token record — + // zero retained state per retired id. Pinned like the + // registration half (appkit_host.m has no SDK test tier); the + // capture and the deinit call site are pinned too so the seam + // can never silently lose its one caller or regress to the live + // read, and the embed cycle and platform-swap tests assert both + // behaviorally against null platform recorders. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) {" }, + // The token-match guard itself: reverting removal to id-keyed + // (deleting whatever the id currently holds, whoever registered + // it) must fail here — the embed suite's survives-teardown test + // pins the same guard behaviorally against the null platform's + // host-font mirror. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!current || current.unsignedLongLongValue != token) return 1;" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" }, + // Teardown must also CLEAR the measured-width NSCache: a + // retiring token's entries can never be served again (tokens + // never repeat) but they stay resident until memory pressure, + // and NSCache cannot enumerate keys, so the whole-cache clear on + // the token-matched path is the only release. Pinned textually + // because no behavioral tier can observe it — only managed app + // builds compile appkit_host.m, so no SDK test can watch the + // ObjC cache empty. The shared accessor and its measure_text + // call site are pinned with the clear so the cache cannot + // quietly retreat to a function-local static the unregister + // path has no way to reach. + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSCache *NativeSdkMeasuredWidthCache(void)" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkMeasuredWidthCache() removeAllObjects];" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSCache *widthCache = NativeSdkMeasuredWidthCache();" }, + .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_unregister_fn = services.unregister_gpu_surface_font_fn," }, + .{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_registration_token = host_token," }, + .{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {};" }, + }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{ .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "resetCursorRects" }, @@ -916,6 +1010,15 @@ pub fn build(b: *std.Build) void { for (desktop_test_shard_specs, desktop_test_shards) |spec, shard_tests| { addTestStep(b, b.fmt("test-desktop-{s}", .{spec.name}), spec.description, shard_tests); } + // The font-registry suite as its own step so CI lanes on real + // Windows hardware can run it natively: the whole font pipeline + // (TrueType parsing, glyph rasterization, the reference renderer) + // is platform-neutral Zig, and this suite's Chinese-receipt test + // registers a committed CJK face through the app-fonts seam and + // proves the rendered string is real glyphs, not tofu — running it + // on a Windows runner makes that a Windows-native receipt. The same + // tests also run inside `zig build test` via the canvas-frame shard. + addTestStep(b, "test-canvas-fonts", "Run the runtime font-registry tests (includes the registered-CJK Chinese receipt)", filteredTestArtifact(b, desktop_mod, "canvas-fonts-tests", &.{"runtime.canvas_font_tests.test"})); addTestStep(b, "test-automation-protocol", "Run automation protocol tests", automation_protocol_tests); addTestStep(b, "test-automation-cli", "Run native automate CLI tests", automation_cli_tests); addTestStep(b, "test-markup-cli", "Run native markup CLI tests", markup_cli_tests); diff --git a/changelog.d/registered-fonts-docs-and-receipt.md b/changelog.d/registered-fonts-docs-and-receipt.md new file mode 100644 index 000000000..a333765cf --- /dev/null +++ b/changelog.d/registered-fonts-docs-and-receipt.md @@ -0,0 +1,3 @@ +improvement: **The tofu guard teaches font registration**: the font-coverage teachings — the `native markup check` error, the Debug view-build diagnostic, and the CLI usage text — now name registering a covering face (`UiApp.Options.fonts`) behind a model binding as the first remedy for text beyond bundled coverage, alongside vector icons and plain words. +- **A fonts page in the docs**: `/fonts` documents registering faces for scripts beyond bundled coverage — the `Options.fonts` scaffold shape, every registration-time validation error by name, ownership and lifecycle, how text resolves faces through the typography tokens, and per-platform truth including the unverified mobile seam. +- **The Chinese receipt runs natively on Windows in CI**: a new `zig build test-canvas-fonts` step runs the font-registry suite on the Windows runner, including the receipt test that registers a committed subsetted Noto Sans SC (OFL, license alongside) through the app-fonts seam and proves the rendered string is real ideograph outlines — compared against both the bundled face's rendering and the same registered face's own uncovered-string fallback, so tofu from any face fails the receipt. diff --git a/changelog.d/registered-fonts-late-rebuild-and-teardown.md b/changelog.d/registered-fonts-late-rebuild-and-teardown.md new file mode 100644 index 000000000..e974b764d --- /dev/null +++ b/changelog.d/registered-fonts-late-rebuild-and-teardown.md @@ -0,0 +1,3 @@ +fix: **Late-registered fonts re-measure open surfaces**: registering a face after views are installed (`runtime.registerCanvasFont` on a live runtime) now rebuilds every installed `UiApp` surface — the main canvas and declared windows — on the next presented frame, so text laid out before the face joined re-measures with the registered face instead of keeping its pre-registration widths under a repaint. +- **macOS host font state ends with its runtime**: `Runtime.deinit` now returns each registered id's host-side registration — the CoreText descriptor and its measurement caches, including the measured-width cache the host previously retained until memory pressure — so embedders that cycle runtimes no longer accumulate per-process font state; removal is ownership-token guarded, so an older runtime's teardown never removes a newer runtime's live face under a shared id. +- **Breaking**: `PlatformServices.registerGpuSurfaceFont` now returns the host's ownership token for the registration (`u64`; 0 from hosts that retain no per-id state), and the new optional `unregisterGpuSurfaceFont(id, token)` service returns that state at teardown — a deliberate break while the toolkit is pre-1.0, so host font lifetime has an owner. Embedders implementing a custom platform change `register_gpu_surface_font_fn` to return a `u64` token (0 is fine for a stateless accept) and may supply `unregister_gpu_surface_font_fn` to release per-id host state when the registering runtime deinits. diff --git a/docs/src/app/fonts/layout.tsx b/docs/src/app/fonts/layout.tsx new file mode 100644 index 000000000..07feee6b6 --- /dev/null +++ b/docs/src/app/fonts/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("fonts"); + +export default function FontsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/docs/src/app/fonts/page.mdx b/docs/src/app/fonts/page.mdx new file mode 100644 index 000000000..aa2d359da --- /dev/null +++ b/docs/src/app/fonts/page.mdx @@ -0,0 +1,87 @@ +# Fonts + +The SDK bundles two faces — Geist Regular and Geist Mono — and every built-in component renders through them by default. Bundled coverage is Latin-script: a codepoint outside it (CJK, emoji, most symbols) draws as the face's notdef box — tofu. Registering a font is how an app renders a script the bundled faces do not cover: register a face that covers the characters, point the typography tokens at it, and both renderers ink the registered outlines. Chinese and Japanese text render this way today. + +## Registering a face + +Declare fonts on `UiApp.Options.fonts` and the app registers them on the installing frame, before the first layout measures anything: + +```zig +const MyApp = native_sdk.UiApp(Model, Msg); + +/// App-chosen id, at or above canvas.min_registered_font_id (64). +/// Store the id in your theme; ids below 64 are reserved for the +/// built-in faces. +pub const cjk_font_id: canvas.FontId = canvas.min_registered_font_id; + +const app_fonts = [_]MyApp.FontRegistration{.{ + .id = cjk_font_id, + .name = "NotoSansSC-Regular.ttf", // teaching errors name this + .ttf = @embedFile("fonts/NotoSansSC-Regular.ttf"), +}}; + +fn tokensFromModel(model: *const Model) canvas.DesignTokens { + var tokens = canvas.DesignTokens.theme(.{ .color_scheme = model.scheme }); + // Text resolves its face through the typography tokens: pointing + // font_id at the registered id covers every ordinary run. Mono + // spans keep the bundled mono face unless you also point + // typography.mono_font_id at a face that covers your script. + tokens.typography.font_id = cjk_font_id; + return tokens; +} + +pub fn options() MyApp.Options { + return .{ + .name = "hello-cjk", + .scene = shell_scene, + .canvas_label = "canvas", + .update = update, + .view = view, // e.g. ui.text(.{}, model.greeting) with greeting = "你好,世界" + .tokens_fn = tokensFromModel, + .fonts = &app_fonts, + }; +} +``` + +The face must be a TrueType (`glyf`) file. The Google Fonts TrueType builds of the Noto CJK faces (Noto Sans SC/TC/JP/KR, OFL-licensed) register as-is; commit the file with its license next to your sources, like any other asset. Underneath `Options.fonts` sits the runtime seam the option calls, one face at a time — embedders and custom lifecycles use it directly: + +```zig +try runtime.registerCanvasFont(cjk_font_id, ttf_bytes); +``` + +Register at startup so the first layout already measures with the face. Late registration still works honestly: `UiApp` surfaces re-measure and rebuild automatically when a face joins the registry, and callers driving the runtime directly re-emit their display lists after registering. + +## Validation is loud and registration-time only + +Every failure happens at registration, never at render time — a registered id always resolves when text draws — with one honest exception: point-matched composite placement is the one per-glyph refusal `maxp` cannot gate at registration (no declared field describes it), so it surfaces at first raster, where the glyph draws the deterministic block fallback — never a crash, never a silent skip. No measured production face places composites that way: the bundled Geist faces and every CJK face measured for the budgets use XY offsets. `Options.fonts` translates each error into a warning naming the font before propagating it: + +- `error.InvalidFontId` — id 0 is the "inherit run font" sentinel and can never hold a face. +- `error.ReservedFontId` — ids below `canvas.min_registered_font_id` (64) belong to the built-in faces. +- `error.FontTooLarge` — the per-font bound is 24 MiB (`canvas_limits.max_registered_canvas_font_bytes`), sized so full CJK files fit: measured Google Fonts TrueType builds run about 9.6 MB (Noto Sans JP) to 17.8 MB (Noto Sans SC). +- `error.FontParseFailed` — not a parseable TrueType `glyf` face; `canvas.font_ttf.parseFailureReason(ttf)` names what is wrong (CFF/OpenType `.otf` files fail here — use the TrueType build). +- `error.FontExceedsGlyphBudgets` — the face's `maxp` table declares glyphs denser than the renderer's outline budgets: 1024 points and 128 contours per glyph, with flattened composites held to the same numbers (at most 4 levels deep, 8 components). The budgets are sized from measured production CJK faces — the densest measured glyph (a 738-point brush kanji in Yuji Mai; Noto Sans TC's worst is 685 points / 87 contours) sits inside them with headroom — so this refuses only outliers whose densest glyphs could not render as outlines. The registration warning names the face's declared maxima against the budgets; `canvas.font_ttf.declaredGlyphMaxima(ttf)` exposes the same numbers to code. +- `error.FontIdInUse` — registration is permanent for the runtime's lifetime and there is deliberately no unregister: glyph caches key by (font id, glyph id) with no content fingerprint, so replacing an id's bytes would serve stale glyphs. Re-registering an id fails; give each face its own id. +- `error.FontRegistryFull` — at most 8 registered faces per runtime (`canvas_limits.max_registered_canvas_fonts`). +- `error.FontHostRegistrationUnsupported` — the platform measures and draws text host-side but has no font registration seam, so the face could not be honored pixel-honestly. No current platform returns this; it exists so a future host can never silently substitute the default family. + +## Ownership and lifecycle + +Registration copies the file into an exact-size heap allocation owned by the runtime, so the caller's buffer (an `@embedFile` slice, a transient read) is free the moment the call returns. A runtime with no registered fonts carries zero font bytes. The copy lives until `Runtime.deinit` — registration is permanent, so ownership is one allocation and one free. Apps that live for the process never think about this; embedders that create and destroy runtimes call `Runtime.deinit` (the SDK's own embed hosts already do). `Runtime.deinit` returns the host-side registration too: on macOS, each registered id's host font state (the CoreText descriptor and its measurement caches) is dropped at deinit, so runtime cycles do not accumulate host entries. Host font state is one face per id per process — concurrent runtimes in one process that share an id resolve the most recent registration; that is a deliberate current constraint, so give concurrently live runtimes disjoint ids. + +## How text finds the face + +There is no per-widget font attribute. Text resolves its face through the typography tokens — `typography.font_id` for every ordinary run, `typography.mono_font_id` for mono-flagged runs, and optional `typography.button_font_id` for control labels — so a registered face is a theme decision, and the id rides everywhere a `canvas.FontId` rides: token overrides, both renderers, glyph atlas keys, render fingerprints. + +Layout and ink stay in lockstep on the desktop paths. On macOS, the raw bytes reach the host at registration time, so CoreText measurement and packet text drawing resolve the exact registered face even when the family is not installed system-wide. On Windows and Linux, layout charges the parsed face's own `cmap`/`hmtx` advances, and the deterministic reference renderer inks the same outlines — CJK advances measure as the face declares them, not as the Latin estimator would guess. Mobile hosts do not yet have a registered-font measurement seam, so this guarantee does not extend there — see the platform notes below. + +Codepoints the registered face does not cover keep the same per-glyph fallback as the built-ins: the face's own notdef box. The engine never silently cascades into another family — the reference renderer, the glyph atlas, and packet text through the SDK's rasterizer show missing coverage as visible notdef, never an approximation. Text the macOS host draws follows platform shaping, including system-font fallback, so uncovered codepoints there may render from a substituted family instead of notdef. + +One honest interaction with the [tofu guard](/native-ui#tooling): the static markup check validates literal text against the *bundled* face's coverage — it runs without your app and cannot know what you register. Text in a script outside bundled coverage reaches the view through model bindings (`{greeting}` — the guard deliberately skips binding spans, and real CJK content is almost always data) or through Zig-built views, where the same lesson is a Debug-build diagnostic rather than an error. + +## Platform truth + +The registration pipeline — TTF parsing, glyph outlines, rasterization — is the SDK's own code, identical on every platform. macOS additionally registers the face with the host so CoreText-side measurement and Metal packet text agree with it. Windows and Linux present the deterministic software renderer, which inks registered outlines directly; the suite pins pixel parity between the present path and the reference path for a registered face, and a CI receipt registers a CJK face and proves Chinese text renders as real glyphs on a native Windows runner. Mobile hosts render the same reference-renderer pixels, but no mobile test exercises registered fonts yet and the mobile hosts' text measurement has no registered-font seam — treat registered fonts on iOS and Android as unverified today. See [Platform Support](/platform-support#support-matrix) for the per-platform matrix. + +## The TypeScript tier + +Font registration is Zig-tier today: TypeScript cores have no fonts surface yet. diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index 69d023f02..711743c62 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -658,7 +658,7 @@ Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSw ## Tooling -- `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — use a vector icon or plain words). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)). +- `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — [register a font](/fonts) that covers it and bind the text from the model, or use a vector icon or plain words; the static check knows only the bundled face's coverage). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)). - `native markup lsp` — diagnostics, completion, and hover in your editor (see `editors/native-markup/` in the repo for VS Code, Helix, and Neovim setup). - The automation harness drives widgets and reads accessibility snapshots and screenshots headlessly — see [Automation](/automation). diff --git a/docs/src/app/platform-support/page.mdx b/docs/src/app/platform-support/page.mdx index bf10dfc74..989dece02 100644 --- a/docs/src/app/platform-support/page.mdx +++ b/docs/src/app/platform-support/page.mdx @@ -38,19 +38,35 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts + + Text + + + + + + + + Registered fonts + + + + + + Pointer, keyboard & IME input - + - - + + Menus - - + + @@ -58,29 +74,29 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts System tray - + Web content (WebViews) - - - - - + + + + + Packaging - - - - - + + + + + Code signing - + @@ -89,23 +105,24 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts Automation - - - - + + + + 1. iOS and Android canvas apps run full screen inside toolkit-owned hosts, which the SDK builds ON the [embed C ABI](/embed) — `native dev` and `native package` with `--target ios|android` generate everything from app.zon, so the app project carries zero host code. Multi-window scenes are desktop-only. Embedding the runtime in a host app you control, driven over the same embed C ABI, works on both platforms and shares the mobile experimental status. 2. macOS presents through Metal and hands scrolling to OS scroll drivers. Linux and Windows present the deterministic software renderer through the platform blit path and report `backend=software`; a manifest that requests another backend falls back to software there instead of erroring. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces. -3. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. -4. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. -5. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. -6. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/web-engines). The mobile shell examples embed the platform WebView as the content workspace. -7. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/packaging). -8. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/packaging/signing). -9. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled. +3. The SDK's own TrueType pipeline — parsing, outlines, rasterization — is text rendering's shared reference path on every platform (goldens, screenshots, and software presents all ink through it), and apps can [register additional faces](/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text presentation resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported. +4. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator. +5. App menus are native on all three desktops. Hosts without a native context-menu presenter — Linux and Windows today — present the same declared context menu as an anchored canvas surface; authors declare one menu either way. +6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected. +7. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/web-engines). The mobile shell examples embed the platform WebView as the content workspace. +8. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/packaging). +9. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/packaging/signing). +10. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled. ## Desktop Hosts diff --git a/docs/src/app/theming/page.mdx b/docs/src/app/theming/page.mdx index eca574f75..25cc0912f 100644 --- a/docs/src/app/theming/page.mdx +++ b/docs/src/app/theming/page.mdx @@ -71,7 +71,7 @@ The register is small enough to read in one sitting; these are the groups and wh - `controls` — per-control visual tables (`button_primary`, `input`, `card`, …). Each entry states background/hover/active/pressed/disabled colors, foreground, border, radius, and stroke width; null fields fall through to the semantic palette and the state formulas. This is where a design system's per-control exceptions live — a filled error button, a 12px card corner — without touching any renderer. - `states` — the interaction formulas applied when a control table is silent: the filled hover/pressed alpha cuts, the disabled wash strength, the destructive chip's wash ladder, the static-text selection wash. - `metrics` — the control size register: the sm/default/lg height ladder, per-rung button insets, button label steps, icon extents and gaps, row extent. -- `typography` — faces and the type rungs (body/label/title/button/heading/display). +- `typography` — faces and the type rungs (body/label/title/button/heading/display). The face slots (`font_id`/`mono_font_id`) take app-registered faces too — see [Fonts](/fonts). - `radius`, `stroke`, `spacing` — the corner scale, hairline/focus stroke widths plus the focus ring's offset gap, and the spacing scale. - `shadow`, `blur`, `motion`, `scroll`, `layer`, `pixel_snap` — elevation, backdrop blur, motion durations/easing, scroll physics, z-bands, and snapping. diff --git a/docs/src/lib/docs-navigation.ts b/docs/src/lib/docs-navigation.ts index 0a9d64f59..57de0e556 100644 --- a/docs/src/lib/docs-navigation.ts +++ b/docs/src/lib/docs-navigation.ts @@ -30,6 +30,7 @@ export const navSections: NavSection[] = [ { name: "Native UI", href: "/native-ui" }, { name: "State & Data Flow", href: "/state" }, { name: "Theming", href: "/theming" }, + { name: "Fonts", href: "/fonts" }, { name: "Building Components", href: "/building-components" }, ], }, diff --git a/docs/src/lib/page-titles.ts b/docs/src/lib/page-titles.ts index 0884280e4..a5dad2021 100644 --- a/docs/src/lib/page-titles.ts +++ b/docs/src/lib/page-titles.ts @@ -13,6 +13,7 @@ export const PAGE_TITLES: Record = { "native-ui": "Native UI", state: "State & Data Flow", theming: "Theming", + fonts: "Fonts", "building-components": "Building Components", runtime: "App & Runtime", frontend: "Frontend Projects", diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index f9c478181..5a628e818 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -1064,7 +1064,7 @@ Tests that touch files or clocks get their `Io` from `std.testing.io`. The full ## Validate without building -`native markup check src/view.native` — instant grammar/structure validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face (⌘, ✓, ⑂, dingbats, CJK) is a teaching error naming the character, because it renders as a tofu box on the reference/screenshot and mobile paths — use a vector icon (`icon=` / ``) or plain words. Dynamic strings get the same lesson as a Debug-build `zero_canvas_ui` diagnostic when the view builds. The accessibility lint rides the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (`--strict` promotes). +`native markup check src/view.native` — instant grammar/structure validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face (⌘, ✓, ⑂, dingbats, CJK) is a teaching error naming the character, because it renders as a tofu box on the reference/screenshot and mobile paths — register a font that covers it (`UiApp.Options.fonts`) and bind the text from the model (the guard skips `{bindings}`), or use a vector icon (`icon=` / ``) or plain words. Dynamic strings get the same lesson as a Debug-build `zero_canvas_ui` diagnostic when the view builds. The accessibility lint rides the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (`--strict` promotes). The model side checks at check time too: the model-contract step (refreshed by `native test`, or run directly as `zig build model-contract` in an app that owns its build) reflects Model/Msg into `zig-out/model-contract.zon`, and `native check` (or `markup check` run in the app directory) then verifies every binding path, iterable, `key` field, message tag, payload type, and expression type against the app's real surface — did-you-mean over your actual field names, and type errors naming the field's Zig type. It also WARNS on model state and Msg tags no view uses; opt update-only names out with `pub const view_unbound = .{ "next_id" };` on Model or Msg (`--strict` turns the warnings into failures) — state consumed only by a Zig-BUILT view needs `view_unbound` too, because the markup checker cannot see Zig view reads. A stale artifact degrades to grammar-only checking with a loud note ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks"); binding paths and message tags are always re-enforced when the app builds (and on hot reload). diff --git a/src/embed/tests.zig b/src/embed/tests.zig index 62370d2ec..b5f25a497 100644 --- a/src/embed/tests.zig +++ b/src/embed/tests.zig @@ -109,9 +109,91 @@ test "embedded app deinit returns registered font bytes across create-destroy cy try embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); try std.testing.expectEqual(@as(usize, 1), embedded.runtime.registeredCanvasFontCount()); try embedded.stop(); + + // Deinit returns BOTH sides of the registration: the Zig-side + // bytes (the leak-checking allocator above) and the host-side + // registration — the runtime must call the platform's + // font-unregister seam per registered id, the call a macOS host + // answers by dropping its CoreText descriptor and caches. The + // null platform records that call; the deferred second deinit + // stays the idempotence backstop. + embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), null_platform.gpu_surface_font_unregister_id); + // The null platform's register seam is off by default, so the + // runtime captured ownership token 0 (nothing installed + // host-side) and the teardown call must carry exactly that. + try std.testing.expectEqual(@as(u64, 0), null_platform.gpu_surface_font_unregister_token); } } +test "embedded app deinit leaves a newer runtime's re-registration of the same font id intact" { + // Host font state is per-process while font ids are only permanent + // per-runtime, so two live runtimes can pass the same id through one + // host: the later registration wins (the documented lifecycle), and + // the OLDER runtime's deinit must return only its own already- + // replaced registration — never the newer runtime's live face, + // which that runtime's engine still holds and draws through. The + // null platform's host-font mirror models the stateful host (the + // shape of macOS's descriptor and token tables): one instance + // stands in for the process, both runtimes register through it, and + // the per-registration ownership token is what the unregister guard + // matches. An id-keyed removal is exactly the regression this test + // catches — it would empty the mirror at A's deinit and fail the + // survives assertion below. + var host_platform = platform.NullPlatform.init(.{}); + host_platform.gpu_surface_font_registrations = true; + var state_a: u8 = 0; + var state_b: u8 = 0; + const embedded_a = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded_a); + const embedded_b = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded_b); + embedded_a.initInPlace(.{ + .context = &state_a, + .name = "embedded-font-owner-a", + .source = platform.WebViewSource.html("

A

"), + }, host_platform.platform()); + defer embedded_a.deinit(); + embedded_b.initInPlace(.{ + .context = &state_b, + .name = "embedded-font-owner-b", + .source = platform.WebViewSource.html("

B

"), + }, host_platform.platform()); + defer embedded_b.deinit(); + embedded_a.runtime.owned_allocator = std.testing.allocator; + embedded_b.runtime.owned_allocator = std.testing.allocator; + + // A registers first, B re-registers the same id: last wins, each + // runtime captured its own never-repeating ownership token. + try embedded_a.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + const token_a = embedded_a.runtime.canvas_font_entries[0].host_registration_token; + try embedded_b.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + const token_b = embedded_b.runtime.canvas_font_entries[0].host_registration_token; + try std.testing.expect(token_a != 0); + try std.testing.expect(token_b != 0); + try std.testing.expect(token_a != token_b); + const live = host_platform.gpuSurfaceFont(canvas.min_registered_font_id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(token_b, live.token); + + // A's deinit presents its stale token: recorded and accepted (the + // registration it owned is already gone), and provably removing + // nothing — B's registration is still the one the host resolves. + embedded_a.deinit(); + try std.testing.expectEqual(@as(usize, 1), host_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), host_platform.gpu_surface_font_unregister_id); + try std.testing.expectEqual(token_a, host_platform.gpu_surface_font_unregister_token); + const survivor = host_platform.gpuSurfaceFont(canvas.min_registered_font_id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(token_b, survivor.token); + + // B's deinit presents the live token and the host state comes out — + // the guard blocks the wrong owner, not teardown itself. + embedded_b.deinit(); + try std.testing.expectEqual(@as(usize, 2), host_platform.gpu_surface_font_unregister_count); + try std.testing.expectEqual(token_b, host_platform.gpu_surface_font_unregister_token); + try std.testing.expect(host_platform.gpuSurfaceFont(canvas.min_registered_font_id) == null); +} + test "embedded app deinit is idempotent" { var null_platform = platform.NullPlatform.init(.{}); var state: u8 = 0; @@ -127,10 +209,46 @@ test "embedded app deinit is idempotent" { // The documented idiom is `defer embedded.deinit()`, which must // compose with an explicit early teardown: the second call is a - // no-op, never a double free. + // no-op, never a double free — and never a double host unregister + // (the first deinit already returned the host-side registration). embedded.deinit(); try std.testing.expectEqual(@as(usize, 0), embedded.runtime.registeredCanvasFontCount()); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); + embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), null_platform.gpu_surface_font_unregister_count); +} + +test "embedded app deinit returns the font registration to the platform that received it" { + // `Runtime.options` is public and mutable, and embedders genuinely + // swap the platform on a live runtime (tests in this repo do it + // constantly). The host-side registration must still come back to + // the host that RECEIVED it: each font entry captures its + // unregistration owner at registration time (the allocator-identity + // freeze, applied to the host seam), so a swap between registration + // and teardown retargets nothing. A deinit reading the live options + // instead would unregister against the swapped-in platform — the + // wrong host, or a null seam — and strand the original host's + // descriptor and caches for the process lifetime. + var platform_a = platform.NullPlatform.init(.{}); + var platform_b = platform.NullPlatform.init(.{}); + var state: u8 = 0; + const embedded = try std.testing.allocator.create(EmbeddedApp); + defer std.testing.allocator.destroy(embedded); + embedded.initInPlace(.{ + .context = &state, + .name = "embedded-font-platform-swap", + .source = platform.WebViewSource.html("

Fonts

"), + }, platform_a.platform()); + embedded.runtime.owned_allocator = std.testing.allocator; + try embedded.runtime.registerCanvasFont(canvas.min_registered_font_id, canvas.font_ttf.geist_mono_bytes); + + // The swap under test: platform B holds the option by teardown time, + // but it never saw the registration. + embedded.runtime.options.platform = platform_b.platform(); embedded.deinit(); + try std.testing.expectEqual(@as(usize, 1), platform_a.gpu_surface_font_unregister_count); + try std.testing.expectEqual(@as(u64, canvas.min_registered_font_id), platform_a.gpu_surface_font_unregister_id); + try std.testing.expectEqual(@as(usize, 0), platform_b.gpu_surface_font_unregister_count); } test "mobile C ABI destroy returns registered font bytes through the embedded deinit" { diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index 5a512cf52..61cc7935d 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -486,8 +486,26 @@ int native_sdk_appkit_measure_text_advances(uint64_t font_id, double size, const // Register engine-validated TrueType bytes under a canvas font id so // measurement and packet text drawing resolve the id to this exact face. -// Returns 1 on success, 0 when CoreText rejects the data. -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len); +// Returns 1 on success, 0 when CoreText rejects the data. On success +// `*out_token` is the registration's ownership token — the handle the +// caller must present to native_sdk_appkit_unregister_font, so teardown +// removes exactly this registration (font ids are per-runtime while +// host font state is per-process: a later runtime may re-register the +// id, and its live face must survive the older owner's teardown). Hosts +// that retain no font state (the Chromium engine's stateless accept) +// write 0: nothing installed, nothing a token could own. +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token); +// Drop the per-id state a registration installed (the descriptor and its +// caches) — the teardown twin Runtime.deinit calls, because font ids are +// per-runtime while this host's font state is per-process. `token` must +// be the value the matching register call reported: state is removed +// only while the id's current registration still carries it, so an +// older runtime's deinit never tears down a newer runtime's live face +// under a shared id. A stale token — like an id with no installed +// descriptor — is a no-op accept: the registration the token owned is +// already gone, which is the state the caller asked for. Returns 1 on +// accept, 0 only for the invalid id 0. +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token); /* Decode encoded image bytes (PNG, JPEG, ... — whatever ImageIO supports) * through CGImageSource into tightly packed, row-major, straight-alpha * (non-premultiplied) RGBA8 written into `pixels`. Returns 1 on success diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index caf97704e..017af50b0 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1763,19 +1763,122 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { return table; } -// The registered face for a canvas font id at `size`, or nil when the id -// has no registered face. Checked BEFORE the built-in candidates and -// their cache so a registered id can never be masked by a font resolved -// for that id earlier (ids are engine-validated and permanent, so cached -// NSFonts here never go stale). -static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { - NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); - static NSMutableDictionary *sizeCache = nil; +// The per-(id, size) NSFont cache for registered faces, keyed "id/size". +// Ids are permanent within ONE runtime (`error.FontIdInUse` guards +// re-use for a runtime's lifetime), but this cache is per-PROCESS and +// runtimes are not: an embedder that destroys its runtime +// (`Runtime.deinit`) and registers a DIFFERENT face under the same +// valid id in a new runtime is inside the documented lifecycle. Entries +// therefore go stale exactly when an id's descriptor is (re)installed, +// so `native_sdk_appkit_register_font` evicts the id's cached sizes +// before installing the new descriptor. The measured-width NSCache +// (`NativeSdkMeasuredWidthCache`) goes stale at the same moment but +// cannot be prefix-evicted (NSCache does not enumerate keys), so it is +// invalidated by registration token instead — see +// NativeSdkRegisteredFontTokens below. Accessed only under the +// descriptor table's @synchronized guard, like the descriptors. +static NSMutableDictionary *NativeSdkRegisteredFontSizeCache(void) { + static NSMutableDictionary *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSMutableDictionary alloc] init]; + }); + return cache; +} + +// Per-id registration tokens, the invalidation handle for the +// measured-width NSCache (`NativeSdkMeasuredWidthCache`). That +// cache has the same lifetime hazard as the size cache above (per- +// process cache, per-runtime id permanence) but no way to evict an +// id's entries — NSCache cannot enumerate keys — so every registration +// stamps its id with the next value of ONE process-global monotonic +// counter and the width-cache key includes the stamp. Tokens never +// repeat, so no registration can ever reach widths a previous life of +// its id cached (re-registration leaves those unreachable entries to +// age out under the cache's count limit; unregistration clears the +// cache outright — see `native_sdk_appkit_unregister_font`) — which is +// exactly what makes an id's record REMOVABLE at unregister, retaining +// zero state per retired id. +// A per-id bump counter could not offer that: deleting a per-id count +// would let a future registration of the id restart at 1 and collide +// with the retired life's still-cached widths, so retired ids would +// each pin a record for the process lifetime. Ids never registered +// (the built-in faces) have no record and measure under token 0 — the +// same value an unregistered id returns to, honest because both states +// resolve through the same built-in candidates. Accessed only under +// the descriptor table's @synchronized guard, like the descriptors. +static NSMutableDictionary *NativeSdkRegisteredFontTokens(void) { + static NSMutableDictionary *table = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + table = [[NSMutableDictionary alloc] init]; + }); + return table; +} + +// The process-global registration token counter: pre-incremented at +// every registration, so 0 is never a live token and stays the honest +// "no registered face" value. Mutated only under the descriptor +// table's @synchronized guard, like every table here. +static unsigned long long NativeSdkRegisteredFontTokenCounter = 0; + +// The measured-width NSCache `native_sdk_appkit_measure_text` memoizes +// shaped widths in, hoisted out of that function (exactly the shared- +// accessor shape the NSFont size cache above took) so +// `native_sdk_appkit_unregister_font` can reach it at teardown: a +// retiring token's entries can never be SERVED again (tokens never +// repeat) but a function-local static left them RESIDENT — up to the +// full count limit of keys, each carrying the measured text itself — +// until memory pressure. NSCache is thread-safe, so unlike the +// dictionaries above this cache is deliberately NOT confined to the +// descriptor table's @synchronized guard: measure_text shapes outside +// the critical section on purpose (see the snapshot comment) and +// re-enters the guard only to recheck the token before a +// registered-token write (see the recheck comment in measure_text). +static NSCache *NativeSdkMeasuredWidthCache(void) { + static NSCache *cache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - sizeCache = [[NSMutableDictionary alloc] init]; + cache = [[NSCache alloc] init]; + cache.countLimit = 16384; }); + return cache; +} + +// The id's current registration token AND the registered face that +// token resolves to, snapshotted under ONE acquisition of the +// descriptor table's @synchronized guard. The pair must be atomic +// because measure_text keys its width memoization by (id, token): with +// the token read and the face resolved under SEPARATE acquisitions, a +// registration landing between them pairs token 0 with the NEW +// registered face and caches a registered-face width under the +// reusable token-0 key — served again after the registration is torn +// down, when token 0 honestly means built-in resolution. One critical +// section makes a torn pair unrepresentable; the width computation +// itself stays outside the lock (see measure_text), because a +// stale-but-consistent pair is harmless: measure_text rechecks the +// token under the guard before caching, so a pair retired mid-shape +// is never even written. Returns nil with +// *out_token = 0 when the id holds no registration, so token 0 only +// ever pairs with built-in resolution. (A registered descriptor whose +// CTFont creation fails also answers nil, under its live token: the +// built-in fallback then caches under that token — still one +// consistent resolution per registration life, since the failure is a +// property of the installed descriptor.) +// +// The registered-face NSFonts cached here stay honest because +// registration evicts an id's entries when its descriptor changes (see +// NativeSdkRegisteredFontSizeCache) — an id is only permanent within +// one runtime, and this cache outlives runtimes. Registered faces are +// checked BEFORE the built-in candidates and their cache so a +// registered id can never be masked by a font resolved for that id +// earlier. +static NSFont *NativeSdkRegisteredFontSnapshot(unsigned long long value, CGFloat size, unsigned long long *out_token) { + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); @synchronized (table) { + NSNumber *tokenNumber = NativeSdkRegisteredFontTokens()[@(value)]; + *out_token = tokenNumber ? tokenNumber.unsignedLongLongValue : 0; id descriptorObject = table[@(value)]; if (!descriptorObject) return nil; NSString *key = [NSString stringWithFormat:@"%llu/%.3f", value, (double)size]; @@ -1789,36 +1892,133 @@ static NSTextAlignment NativeSdkPacketTextAlignment(NSString *align) { } } +// The registered face for a canvas font id at `size`, or nil when the id +// has no registered face — the snapshot above for callers that resolve +// but do not memoize by token (packet drawing, the advances batch). +static NSFont *NativeSdkRegisteredFontForId(unsigned long long value, CGFloat size) { + unsigned long long token = 0; + return NativeSdkRegisteredFontSnapshot(value, size, &token); +} + // Engine-validated TrueType bytes for a registered canvas font id: parse // them into a font descriptor once and key it by id, so measurement and // packet text drawing resolve the id to this exact face. Returns 1 on // success, 0 when CoreText rejects the data — the engine already parsed // the face, so a rejection here is surfaced as a loud registration -// error engine-side, never a silent fallback at draw time. -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len) { - if (font_id == 0 || !bytes || bytes_len == 0) return 0; +// error engine-side, never a silent fallback at draw time. On success +// `*out_token` reports the registration token minted below — the +// ownership handle unregister_font matches against, so a teardown can +// only remove the registration it owns. +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token) { + if (out_token) *out_token = 0; + if (font_id == 0 || !bytes || bytes_len == 0 || !out_token) return 0; @autoreleasepool { NSData *data = [NSData dataWithBytes:bytes length:bytes_len]; CTFontDescriptorRef descriptor = CTFontManagerCreateFontDescriptorFromData((__bridge CFDataRef)data); if (!descriptor) return 0; NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); @synchronized (table) { + // Evict the id's cached NSFonts BEFORE installing the new + // descriptor: the process may still hold fonts a PREVIOUS + // runtime resolved for this id (ids are per-runtime + // permanent, the cache is per-process), and serving them + // would measure and draw the old face under the new id. + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); + NSString *stalePrefix = [NSString stringWithFormat:@"%llu/", (unsigned long long)font_id]; + NSArray *cachedKeys = sizeCache.allKeys; + for (NSString *cachedKey in cachedKeys) { + if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; + } + // The measured-width cache holds the same stale state but + // cannot be enumerated for eviction, so stamp the id with a + // fresh process-global token instead: width-cache keys + // include the token, tokens never repeat, and everything + // cached under any previous stamp becomes unreachable. The + // stamp doubles as the registration's ownership token, + // reported to the caller so its teardown can name exactly + // this registration. + unsigned long long token = ++NativeSdkRegisteredFontTokenCounter; + NativeSdkRegisteredFontTokens()[@(font_id)] = @(token); table[@(font_id)] = (__bridge_transfer id)descriptor; + *out_token = token; } return 1; } } -// Resolves a canvas font id to the NSFont presentation draws with. Both -// packet text drawing and native_sdk_appkit_measure_text go through this -// single function so measured layout and drawn glyphs share font -// resolution. Ids 3-6 are the reserved sans span variants (medium, bold, -// italic, bold italic); everything else keeps the regular sans/mono -// candidates. Registered faces win first (see above). Resolved -// built-in fonts are cached per (font id, size). -static NSFont *NativeSdkFontForFontId(unsigned long long value, CGFloat size) { - NSFont *registered = NativeSdkRegisteredFontForId(value, size); - if (registered) return registered; +// Teardown twin of the registration above, called by Runtime.deinit for +// each id the dying runtime registered: font ids are per-runtime but +// every table here is per-process, so without this removal an embedder +// cycling runtimes with fresh ids grows the descriptor table (and its +// caches) for the process lifetime. Removal drops EVERYTHING the +// registration installed: the id's cached NSFonts (the same prefix +// eviction re-registration runs), the id's registration token record, +// the descriptor itself, and — by clearing the measured-width NSCache +// wholesale, the only eviction NSCache permits — the widths measured +// under the retiring token. Zero retained state per retired id, so +// runtime cycles truly do not accumulate host entries. Deleting the +// token record (rather than bumping it) is safe precisely because +// tokens come from one process-global monotonic counter: a future +// registration of this id takes a fresh, never-repeated token, so no +// per-id record has to survive to keep this life's width-cache keys +// unreachable. An id +// with no installed descriptor (never registered host-side, or already +// returned) is a no-op accept. +// +// Removal is token-guarded: `token` is the ownership handle the id's +// register call reported, and state comes out ONLY while the id's +// current registration still carries it. Font ids are per-runtime while +// this state is per-process, so a later runtime may have re-registered +// the id (last wins — its face is the live one measurement and drawing +// resolve); an id-keyed removal here would let the OLDER runtime's +// deinit delete the newer runtime's descriptor and caches, dropping its +// host text to the default family while its engine still holds the +// face. A stale token is a no-op accept, not an error: the registration +// it owned is already gone, which is exactly the state its owner asked +// this call to establish. +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { + if (font_id == 0) return 0; + @autoreleasepool { + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + @synchronized (table) { + NSNumber *current = NativeSdkRegisteredFontTokens()[@(font_id)]; + if (!current || current.unsignedLongLongValue != token) return 1; + NSMutableDictionary *sizeCache = NativeSdkRegisteredFontSizeCache(); + NSString *stalePrefix = [NSString stringWithFormat:@"%llu/", (unsigned long long)font_id]; + NSArray *cachedKeys = sizeCache.allKeys; + for (NSString *cachedKey in cachedKeys) { + if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey]; + } + [NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)]; + [table removeObjectForKey:@(font_id)]; + // The retiring token's measured widths can never be served + // again (tokens never repeat) but would otherwise stay + // resident until memory pressure. NSCache cannot enumerate + // keys, so a per-id eviction is impossible and clearing the + // WHOLE cache is the only honest release. The collateral is + // cheap where it lands: unregistration is a teardown- + // frequency event, and every other live id re-warms each + // width it still needs in one measure call. The clear runs + // only on this token-matched path — a stale-token no-op + // above must not cost live registrations their warm cache. + // A measurement in flight during this teardown cannot + // repopulate the cleared cache: measure_text rechecks the + // id's token under this same guard before caching, and this + // removal already retired the token it snapshotted. + [NativeSdkMeasuredWidthCache() removeAllObjects]; + } + return 1; + } +} + +// The built-in-candidates half of font resolution: never consults the +// registered tables, so callers that already hold a registered-face +// snapshot (measure_text) fall back here without re-reading state the +// snapshot fixed. Ids 3-6 are the reserved sans span variants (medium, +// bold, italic, bold italic); everything else keeps the regular +// sans/mono candidates. Resolved built-in fonts are cached per +// (font id, size). +static NSFont *NativeSdkBuiltInFontForFontId(unsigned long long value, CGFloat size) { static NSCache *cache = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -1866,6 +2066,19 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size return font; } +// Resolves a canvas font id to the NSFont presentation draws with. Both +// packet text drawing and native_sdk_appkit_measure_text go through this +// resolution order (registered faces win, then the built-in candidates) +// so measured layout and drawn glyphs share font resolution — +// measure_text inlines the two halves around its width cache because it +// must key that cache by the registration-token snapshot the first half +// fixes. +static NSFont *NativeSdkFontForFontId(unsigned long long value, CGFloat size) { + NSFont *registered = NativeSdkRegisteredFontForId(value, size); + if (registered) return registered; + return NativeSdkBuiltInFontForFontId(value, size); +} + static NSFont *NativeSdkPacketPreferredFont(NSDictionary *text, CGFloat size) { NSNumber *fontId = [text[@"font"] isKindOfClass:[NSNumber class]] ? text[@"font"] : nil; unsigned long long value = fontId ? fontId.unsignedLongLongValue : 1; @@ -1883,19 +2096,58 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size @autoreleasepool { NSString *value = [[NSString alloc] initWithBytes:text length:text_len encoding:NSUTF8StringEncoding]; if (!value) return -1; - static NSCache *widthCache = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - widthCache = [[NSCache alloc] init]; - widthCache.countLimit = 16384; - }); - NSString *key = [NSString stringWithFormat:@"%llu/%.3f/%@", (unsigned long long)font_id, (double)clamped, value]; + NSCache *widthCache = NativeSdkMeasuredWidthCache(); + // The key carries the id's registration token so a face + // re-registered under the same id (a new runtime in this + // process) never serves a previous face's widths — see + // NativeSdkRegisteredFontTokens for why token, not eviction, + // invalidates this cache. Token and registered face come from + // ONE snapshot (one critical section — see + // NativeSdkRegisteredFontSnapshot): reading them under separate + // acquisitions let a registration land between the reads and + // cache the new face's width under the reusable token-0 key, + // serving stale registered widths in the unregistered state + // after teardown. + unsigned long long token = 0; + NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token); + NSString *key = [NSString stringWithFormat:@"%llu/%llu/%.3f/%@", (unsigned long long)font_id, token, (double)clamped, value]; NSNumber *cached = [widthCache objectForKey:key]; if (cached) return cached.doubleValue; - NSFont *font = NativeSdkFontForFontId(font_id, clamped); + // A nil snapshot means built-in resolution — with token 0 by the + // snapshot's contract unless a live descriptor's CTFont creation + // failed, so token 0 only ever keys built-in widths. Shaping + // stays OUTSIDE the critical section: a registration or + // unregistration landing after the snapshot leaves this width + // stale but consistent, and the token recheck below drops the + // write instead of caching it. + NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped); if (!font) return -1; double width = [value sizeWithAttributes:@{ NSFontAttributeName : font }].width; - [widthCache setObject:@(width) forKey:key]; + if (token == 0) { + // Token 0 is built-in resolution: built-in faces are never + // registered, so no unregister can ever clear widths keyed + // under it — this write needs no recheck. + [widthCache setObject:@(width) forKey:key]; + return width; + } + // A registered-token width is cached only after RECHECKING the + // token under the guard. Unregister's only possible eviction is + // clearing the whole width cache, and shaping ran outside the + // critical section: an unconditional write here would land AFTER + // that clear whenever the teardown ran inside the shaping window, + // repopulating the cleared cache with a retired-token entry — one + // no lookup can ever serve (tokens never repeat) but that stays + // resident until memory pressure. A mismatch means the + // registration was torn down (or replaced) mid-shape — dropping + // the write costs one re-measure of a face that is already gone, + // and keeps teardown's cleared cache actually clear. + NSMutableDictionary *table = NativeSdkRegisteredFontDescriptors(); + @synchronized (table) { + NSNumber *currentToken = NativeSdkRegisteredFontTokens()[@(font_id)]; + if (currentToken && currentToken.unsignedLongLongValue == token) { + [widthCache setObject:@(width) forKey:key]; + } + } return width; } } @@ -1910,6 +2162,14 @@ int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size // call per text run replaces one measure_text round-trip per cluster of // every growing line prefix — the engine caches the batch, so a run is // typically shaped here once per content change, not once per frame. +// +// Unlike measure_text this needs no registration-token snapshot: it +// memoizes nothing host-side (the engine caches the batch and +// invalidates it by its own measure generation, bumped at every +// registration), so each call resolves the font once and uses it +// immediately — there is no (token, face) pairing that could tear +// across lock acquisitions or outlive the registration it was read +// from. int native_sdk_appkit_measure_text_advances(uint64_t font_id, double size, const char *text, size_t text_len, float *advances) { if (!text || text_len == 0 || !advances) return 0; CGFloat clamped = MAX(1, size); diff --git a/src/platform/macos/cef_host.mm b/src/platform/macos/cef_host.mm index ad133716f..c629c4c69 100644 --- a/src/platform/macos/cef_host.mm +++ b/src/platform/macos/cef_host.mm @@ -2461,8 +2461,22 @@ int native_sdk_appkit_update_widget_accessibility(native_sdk_appkit_host_t *host * registration so `Options.fonts` apps start identically under both * hosts; refusing here would fail startup for a face this host never * resolves. */ -int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len) { - if (font_id == 0 || !bytes || bytes_len == 0) return 0; +int native_sdk_appkit_register_font(uint64_t font_id, const uint8_t *bytes, size_t bytes_len, uint64_t *out_token) { + /* Token 0 is the honest report for a stateless accept: nothing was + * installed, so there is no registration a token could own and + * nothing a later unregister could remove. */ + if (out_token) *out_token = 0; + if (font_id == 0 || !bytes || bytes_len == 0 || !out_token) return 0; + return 1; +} + +/* Teardown twin of the stateless accept above: registration retained no + * host state here, so Runtime.deinit's per-id unregister has nothing to + * return — accept it (whatever token rides it) for the same + * start-identically reason. */ +int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) { + (void)token; + if (font_id == 0) return 0; return 1; } diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 32969fbe2..22a8e1a55 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -187,7 +187,8 @@ extern fn native_sdk_appkit_close_webview(host: *AppKitHost, window_id: u64, lab extern fn native_sdk_appkit_clipboard_read(host: *AppKitHost, buffer: [*]u8, buffer_len: usize) usize; extern fn native_sdk_appkit_measure_text(font_id: u64, size: f64, text: [*]const u8, text_len: usize) f64; extern fn native_sdk_appkit_measure_text_advances(font_id: u64, size: f64, text: [*]const u8, text_len: usize, advances: [*]f32) c_int; -extern fn native_sdk_appkit_register_font(font_id: u64, bytes: [*]const u8, bytes_len: usize) c_int; +extern fn native_sdk_appkit_register_font(font_id: u64, bytes: [*]const u8, bytes_len: usize, out_token: *u64) c_int; +extern fn native_sdk_appkit_unregister_font(font_id: u64, token: u64) c_int; extern fn native_sdk_appkit_register_bundled_fonts() void; extern fn native_sdk_appkit_decode_image(bytes: [*]const u8, bytes_len: usize, pixels: [*]u8, pixels_len: usize, out_width: *usize, out_height: *usize) c_int; extern fn native_sdk_appkit_clipboard_write(host: *AppKitHost, text: [*]const u8, text_len: usize) void; @@ -640,6 +641,7 @@ pub const MacPlatform = struct { .upload_gpu_surface_image_fn = uploadGpuSurfaceImage, .remove_gpu_surface_image_fn = removeGpuSurfaceImage, .register_gpu_surface_font_fn = registerGpuSurfaceFont, + .unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont, .update_widget_accessibility_fn = updateWidgetAccessibility, .measure_text_fn = measureText, .measure_text_advances_fn = measureTextAdvances, @@ -932,6 +934,7 @@ pub fn installHeadlessTextServices(services: *platform_mod.PlatformServices) voi services.measure_text_fn = measureText; services.measure_text_advances_fn = measureTextAdvances; services.register_gpu_surface_font_fn = registerGpuSurfaceFont; + services.unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont; } fn measureText(context: ?*anyopaque, font_id: u64, size: f32, text: []const u8) f32 { @@ -1480,10 +1483,30 @@ fn removeGpuSurfaceImage(context: ?*anyopaque, id: u64) anyerror!void { /// Registered canvas fonts feed the host's single font-resolution seam /// (measurement AND packet text drawing), which exists on both web -/// engines, so this is deliberately not gated on `web_engine`. -fn registerGpuSurfaceFont(context: ?*anyopaque, font: platform_mod.GpuSurfaceFontData) anyerror!void { +/// engines, so this is deliberately not gated on `web_engine`. Returns +/// the registration's ownership token: the AppKit host mints one per +/// registration (font state there is per-process, so the token is what +/// lets teardown remove exactly this registration under a shared id); +/// the Chromium host retains no font state and reports 0 — nothing to +/// own, nothing for an unregister to remove. +fn registerGpuSurfaceFont(context: ?*anyopaque, font: platform_mod.GpuSurfaceFontData) anyerror!u64 { _ = context; - if (native_sdk_appkit_register_font(font.id, font.ttf.ptr, font.ttf.len) == 0) return error.InvalidGpuSurfaceFont; + var token: u64 = 0; + if (native_sdk_appkit_register_font(font.id, font.ttf.ptr, font.ttf.len, &token) == 0) return error.InvalidGpuSurfaceFont; + return token; +} + +/// Teardown twin of the registration above: drop the host's per-id font +/// state (the CoreText descriptor and its caches) when the runtime that +/// registered the id deinits — but only while the id's current +/// registration still carries `token`, so an older runtime's deinit +/// never removes a face a newer runtime re-registered under the same +/// id. Same seam as registration, so deliberately not gated on +/// `web_engine` either; unregistering an id the host never saw, or with +/// a stale token, is a no-op accept. +fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64, token: u64) anyerror!void { + _ = context; + if (native_sdk_appkit_unregister_font(id, token) == 0) return error.InvalidGpuSurfaceFont; } fn updateWidgetAccessibility(context: ?*anyopaque, snapshot: platform_mod.WidgetAccessibilitySnapshot) anyerror!void { diff --git a/src/platform/null_platform.zig b/src/platform/null_platform.zig index 7491a3fe6..dbc36e5e3 100644 --- a/src/platform/null_platform.zig +++ b/src/platform/null_platform.zig @@ -125,6 +125,7 @@ const GpuSurfaceInputEvent = types.GpuSurfaceInputEvent; const GpuSurfacePixels = types.GpuSurfacePixels; const GpuSurfacePacket = types.GpuSurfacePacket; const GpuSurfaceImagePixels = types.GpuSurfaceImagePixels; +const GpuSurfaceFontData = types.GpuSurfaceFontData; const max_gpu_surface_scroll_drivers = types.max_gpu_surface_scroll_drivers; const GpuSurfaceScrollDriver = types.GpuSurfaceScrollDriver; const max_context_menu_items = types.max_context_menu_items; @@ -161,6 +162,20 @@ pub const NullGpuSurfaceImage = struct { sample_rgba: [4]u8 = .{ 0, 0, 0, 0 }, }; +/// Matches the runtime font registry's slot count +/// (`canvas_limits.max_registered_canvas_fonts`). +pub const max_gpu_surface_fonts: usize = 16; + +/// One mirrored host font registration (see `gpu_surface_fonts`): the +/// id-keyed record a stateful host retains per registered face, plus the +/// ownership token the register call reported for it — the shape of the +/// AppKit host's descriptor-and-token tables, minus the CoreText. +pub const NullGpuSurfaceFont = struct { + id: u64 = 0, + token: u64 = 0, + byte_len: usize = 0, +}; + pub const NullTimer = struct { id: u64 = 0, interval_ns: u64 = 0, @@ -433,6 +448,40 @@ pub const NullPlatform = struct { gpu_surface_image_upload_byte_len: usize = 0, gpu_surface_image_upload_sample_rgba: [4]u8 = .{ 0, 0, 0, 0 }, gpu_surface_image_remove_id: u64 = 0, + /// Host-font mirror for the registration seam, default OFF: the null + /// platform models a platform without host-side text, whose register + /// seam is null (engine-side registration is the whole story there, + /// and the runtime records ownership token 0 — nothing installed). + /// Tests modelling a stateful host (macOS's per-process descriptor + /// and token tables) enable this before taking `platform()`: the + /// mirror then accepts registrations id-keyed and last-wins, mints a + /// monotonic ownership token per registration exactly like the + /// AppKit host, and honors the token guard at unregister — so the + /// shared-id lifecycle (an older runtime's deinit must leave a newer + /// runtime's re-registration intact) is assertable without CoreText. + /// Per-instance where the real host tables are per-process: tests + /// model "one process" by pointing every runtime at ONE instance. + gpu_surface_font_registrations: bool = false, + gpu_surface_fonts: [max_gpu_surface_fonts]NullGpuSurfaceFont = [_]NullGpuSurfaceFont{.{}} ** max_gpu_surface_fonts, + gpu_surface_font_count: usize = 0, + gpu_surface_font_register_count: usize = 0, + /// The mirror's monotonic token counter, pre-incremented per + /// registration so 0 is never a live token — the AppKit host's + /// counter discipline, which is what makes a stale (or 0) token + /// provably match nothing. + gpu_surface_font_token_counter: u64 = 0, + /// Font-unregister recorder for the host-teardown seam Runtime.deinit + /// drives. Present even with the mirror off: the null platform then + /// has no register seam (no host-side text — engine-side registration + /// is the whole story), but it still accepts and records the teardown + /// call, so embed tests can pin that deinit returns the host-side + /// registration on platforms that do retain per-id font state + /// (macOS's CoreText descriptor and caches). The recorded token is + /// exactly what the call carried — 0 when the runtime captured no + /// register-time token, the minted stamp when the mirror is on. + gpu_surface_font_unregister_count: usize = 0, + gpu_surface_font_unregister_id: u64 = 0, + gpu_surface_font_unregister_token: u64 = 0, timers: [max_null_timers]NullTimer = [_]NullTimer{.{}} ** max_null_timers, timer_count: usize = 0, timer_start_count: usize = 0, @@ -619,6 +668,8 @@ pub const NullPlatform = struct { .present_gpu_surface_packet_binary_fn = presentGpuSurfacePacketBinary, .upload_gpu_surface_image_fn = uploadGpuSurfaceImage, .remove_gpu_surface_image_fn = removeGpuSurfaceImage, + .register_gpu_surface_font_fn = if (self.gpu_surface_font_registrations) registerGpuSurfaceFont else null, + .unregister_gpu_surface_font_fn = unregisterGpuSurfaceFont, .decode_image_fn = decodeImage, .set_gpu_surface_scroll_drivers_fn = setGpuSurfaceScrollDrivers, .show_context_menu_fn = if (self.context_menus) showContextMenu else null, @@ -1761,6 +1812,69 @@ pub const NullPlatform = struct { self.gpu_surface_image_count = last; } + /// The host-font mirror's register half (installed only when + /// `gpu_surface_font_registrations` is on — see that flag): id-keyed + /// and last-wins like the AppKit host's descriptor table, minting a + /// pre-incremented monotonic ownership token per registration and + /// returning it, so tests can assert the token lifecycle the real + /// host runs. + fn registerGpuSurfaceFont(context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!u64 { + const self: *NullPlatform = @ptrCast(@alignCast(context.?)); + self.gpu_surface_font_register_count += 1; + self.gpu_surface_font_token_counter += 1; + const token = self.gpu_surface_font_token_counter; + const index = self.findGpuSurfaceFontIndex(font.id) orelse blk: { + if (self.gpu_surface_font_count >= max_gpu_surface_fonts) return error.InvalidGpuSurfaceFont; + const index = self.gpu_surface_font_count; + self.gpu_surface_font_count += 1; + break :blk index; + }; + self.gpu_surface_fonts[index] = .{ + .id = font.id, + .token = token, + .byte_len = font.ttf.len, + }; + return token; + } + + /// Records every teardown call (see the recorder fields; unlike the + /// image seam this one is not gated on `gpu_surfaces` — Runtime.deinit + /// unregisters every registered font regardless of what the surface + /// renders through), then applies the mirror's token guard: the id's + /// entry comes out only while it still carries `token`, so a stale + /// owner (the id was re-registered since, last wins) removes nothing + /// — the guard an id-keyed removal would violate by letting an older + /// runtime's deinit delete a newer runtime's live registration. With + /// the mirror off the table is empty and this is the recording no-op + /// it always was: there is no host font state to drop. + fn unregisterGpuSurfaceFont(context: ?*anyopaque, id: u64, token: u64) anyerror!void { + const self: *NullPlatform = @ptrCast(@alignCast(context.?)); + self.gpu_surface_font_unregister_id = id; + self.gpu_surface_font_unregister_token = token; + self.gpu_surface_font_unregister_count += 1; + const index = self.findGpuSurfaceFontIndex(id) orelse return; + if (self.gpu_surface_fonts[index].token != token) return; + const last = self.gpu_surface_font_count - 1; + if (index != last) self.gpu_surface_fonts[index] = self.gpu_surface_fonts[last]; + self.gpu_surface_fonts[last] = .{}; + self.gpu_surface_font_count = last; + } + + /// The mirrored host font registration for `id`, or null when the id + /// holds none (never registered, or returned at teardown) — what a + /// stateful host would resolve measurement and drawing through. + pub fn gpuSurfaceFont(self: *const NullPlatform, id: u64) ?NullGpuSurfaceFont { + const index = self.findGpuSurfaceFontIndex(id) orelse return null; + return self.gpu_surface_fonts[index]; + } + + fn findGpuSurfaceFontIndex(self: *const NullPlatform, id: u64) ?usize { + for (self.gpu_surface_fonts[0..self.gpu_surface_font_count], 0..) |font, index| { + if (font.id == id) return index; + } + return null; + } + /// The recorded side-channel entry for `id`, or null when the id was /// never uploaded (or has been removed) — the store a packet host /// would consult on an upload cache action. diff --git a/src/platform/types.zig b/src/platform/types.zig index 21ab1b133..42e95d619 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -1783,8 +1783,11 @@ pub const GpuSurfaceImagePixels = struct { /// keyed by the canvas font id host-wide. The engine validates the bytes /// (a parseable TrueType face under the registry bounds) before this /// call, so hosts may treat a decode failure as a hard error rather than -/// a fallback. Ids are permanent for the process — the engine never -/// re-registers an id with different bytes. +/// a fallback. Ids are permanent per RUNTIME (one runtime never +/// re-registers an id with different bytes), but host font state is +/// per-process and runtimes are not: a later runtime may re-register an +/// id a still-live earlier runtime also holds, and the host resolves the +/// most recent registration (last wins). pub const GpuSurfaceFontData = struct { id: u64, ttf: []const u8, @@ -2204,7 +2207,34 @@ pub const PlatformServices = struct { /// without host-side text (GTK/Win32/null default), where the engine /// measures with the parsed face and inks it through the reference /// renderer. - register_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!void = null, + /// + /// Returns the host's ownership token for THIS registration of the + /// id. Host font state is per-process while ids are only permanent + /// per-runtime, so two live runtimes can register the same id (last + /// wins); the token names which registration a later unregister may + /// remove — the runtime stores it beside the captured unregister + /// owner and passes it back at teardown. Platforms that retain no + /// per-id host state (a stateless accept) return 0: there was + /// nothing installed, so there is nothing a token could own, and an + /// unregister carrying 0 removes nothing. + register_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, font: GpuSurfaceFontData) anyerror!u64 = null, + /// Teardown twin of the registration seam above: return whatever + /// per-id state the host retained for a registered face (descriptor + /// tables, size/measurement caches) when the runtime that registered + /// the id deinits. Fonts are per-runtime but host font state is + /// per-process, so without this seam an embedder cycling runtimes + /// with fresh ids grows host state for the process lifetime. + /// `token` is the ownership token the matching register call + /// returned: the host removes the id's state ONLY while the id's + /// current registration still carries that token, so an older + /// runtime's deinit can never tear down a newer runtime's live face + /// under a shared id. A stale token (the id was re-registered since) + /// is a no-op accept — the registration it owned is already gone, + /// which is exactly the state its owner asked for. Unregistering an + /// id the host never saw is likewise a no-op, not an error. Null on + /// platforms whose register seam is itself null (GTK/Win32 default) + /// — with no host font state there is nothing to return. + unregister_gpu_surface_font_fn: ?*const fn (context: ?*anyopaque, id: u64, token: u64) anyerror!void = null, update_widget_accessibility_fn: ?*const fn (context: ?*anyopaque, snapshot: WidgetAccessibilitySnapshot) anyerror!void = null, /// Reconcile the native scroll drivers for a gpu-surface view against /// the full desired set: create missing drivers, update frames / @@ -2695,13 +2725,25 @@ pub const PlatformServices = struct { return remove_fn(self.context, id); } - pub fn registerGpuSurfaceFont(self: PlatformServices, font: GpuSurfaceFontData) anyerror!void { + /// Returns the host's ownership token for this registration (0 from + /// hosts that retain no per-id state) — see + /// `register_gpu_surface_font_fn`. + pub fn registerGpuSurfaceFont(self: PlatformServices, font: GpuSurfaceFontData) anyerror!u64 { if (font.id == 0) return error.InvalidGpuSurfaceFont; if (font.ttf.len == 0 or font.ttf.len > max_gpu_surface_font_bytes) return error.InvalidGpuSurfaceFont; const register_fn = self.register_gpu_surface_font_fn orelse return error.UnsupportedService; return register_fn(self.context, font); } + /// `token` must be the value the matching `registerGpuSurfaceFont` + /// returned; a stale token is a no-op accept — see + /// `unregister_gpu_surface_font_fn`. + pub fn unregisterGpuSurfaceFont(self: PlatformServices, id: u64, token: u64) anyerror!void { + if (id == 0) return error.InvalidGpuSurfaceFont; + const unregister_fn = self.unregister_gpu_surface_font_fn orelse return error.UnsupportedService; + return unregister_fn(self.context, id, token); + } + pub fn updateWidgetAccessibility(self: PlatformServices, snapshot: WidgetAccessibilitySnapshot) anyerror!void { if (snapshot.view_label.len == 0 or snapshot.view_label.len > max_view_label_bytes) return error.InvalidViewOptions; if (snapshot.nodes.len > max_widget_accessibility_nodes) return error.InvalidViewOptions; diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 902d31d31..964bef490 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -96,12 +96,16 @@ fn warnTextSizeKind(kind: WidgetKind, size: canvas.WidgetSize) void { /// (`automate screenshot`), mobile embeds, provider-less measurement. /// Markup literals get the same lesson as a validation error; this /// diagnostic is the net for DYNAMIC strings (model-derived text -/// reaches no static check) and Zig-authored literals. Logs the first -/// uncovered codepoint per text run and keeps building — live macOS -/// rendering falls back through CoreText, so this must never fail an -/// app, and it logs at .debug (the `logAxisChildrenOverflow` -/// precedent) because a .warn inside a test-built view would fail the -/// whole suite for a rendering nit. +/// reaches no static check) and Zig-authored literals. The check +/// knows only bundled coverage — a builder has no view of the +/// runtime's font registry — so a run whose tokens point at a +/// registered face covering the codepoint renders real glyphs and +/// still logs here; that imprecision is one reason this logs at +/// .debug. Logs the first uncovered codepoint per text run and keeps +/// building — live macOS rendering falls back through CoreText, so +/// this must never fail an app, and .debug (the +/// `logAxisChildrenOverflow` precedent) because a .warn inside a +/// test-built view would fail the whole suite for a rendering nit. fn warnUncoveredText(kind: WidgetKind, text: []const u8) void { if (builtin.mode != .Debug) return; var index: usize = 0; @@ -111,7 +115,7 @@ fn warnUncoveredText(kind: WidgetKind, text: []const u8) void { const codepoint = std.unicode.utf8Decode(text[index .. index + len]) catch return; if (codepoint >= 0x20 and codepoint != 0x7F and !font_coverage.covers(codepoint)) { ui_log.debug( - "{s} text contains \"{s}\" (U+{X:0>4}), outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; use a vector icon (icon option, ) or plain words", + "{s} text contains \"{s}\" (U+{X:0>4}), outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths unless a registered font covers it; register a font (UiApp Options.fonts), use a vector icon (icon option, ), or plain words", .{ @tagName(kind), text[index .. index + len], codepoint }, ); return; diff --git a/src/primitives/canvas/ui_markup.zig b/src/primitives/canvas/ui_markup.zig index ebb164587..dbb59a61c 100644 --- a/src/primitives/canvas/ui_markup.zig +++ b/src/primitives/canvas/ui_markup.zig @@ -1675,7 +1675,7 @@ fn trimBlank(text: []const u8) []const u8 { return std.mem.trim(u8, text, " \t\r\n"); } -pub const font_coverage_message = "this text contains a character outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; use a vector icon ( or the icon attribute) or plain words"; +pub const font_coverage_message = "this text contains a character outside the bundled font's coverage - it renders as a tofu box on the reference/screenshot and mobile paths; register a font that covers it (UiApp Options.fonts) and bind the text from the model, use a vector icon ( or the icon attribute), or plain words"; pub const UncoveredCodepoint = struct { /// Byte offset of the codepoint within the scanned literal. diff --git a/src/primitives/canvas/ui_markup_view_tests.zig b/src/primitives/canvas/ui_markup_view_tests.zig index 73a92c4e5..82a6080cc 100644 --- a/src/primitives/canvas/ui_markup_view_tests.zig +++ b/src/primitives/canvas/ui_markup_view_tests.zig @@ -3907,7 +3907,8 @@ test "reactions misuse fails the build with the pinned teaching messages" { // The pill's literal run rides the tofu guard: a codepoint outside // the bundled face renders as a tofu box on the reference path, so - // the validator teaches vector icons or plain words instead. + // the validator teaches the real paths — a registered covering + // font behind a binding, a vector icon, or plain words. const emoji = "\n hi\u{1F44D}\n"; var parser = canvas.ui_markup.Parser.init(arena, emoji); const document = try parser.parse(); diff --git a/src/runtime/canvas_font_tests.zig b/src/runtime/canvas_font_tests.zig index 47ddb28b9..e964db9c1 100644 --- a/src/runtime/canvas_font_tests.zig +++ b/src/runtime/canvas_font_tests.zig @@ -510,8 +510,8 @@ test "a registered face renders pixel-identically on the present path and the re // ------------------------------------------------ UiApp Options.fonts -const FontAppModel = struct { presses: u32 = 0 }; -const FontAppMsg = union(enum) { press: void }; +const FontAppModel = struct { presses: u32 = 0, show_uncovered_cjk: bool = false }; +const FontAppMsg = union(enum) { press: void, show_uncovered_cjk: void }; const FontApp = ui_app_model.UiApp(FontAppModel, FontAppMsg); const font_app_canvas_label = "canvas"; @@ -519,6 +519,7 @@ const font_app_canvas_label = "canvas"; fn fontAppUpdate(model: *FontAppModel, msg: FontAppMsg) void { switch (msg) { .press => model.presses += 1, + .show_uncovered_cjk => model.show_uncovered_cjk = true, } } @@ -642,6 +643,442 @@ test "ui app declared font failures are teaching errors, not crashes or silent f try std.testing.expectEqual(first_errors, harness.runtime.dispatchErrorTotal()); } +// ---------------------------------------------- the Chinese receipt + +/// Committed CJK fixture: Noto Sans SC (OFL — the license rides beside +/// the file in testdata/fonts/OFL.txt), instanced at wght=400 from the +/// Google Fonts variable TrueType build and subsetted to exactly the +/// receipt string's four ideographs plus notdef (2.2 KB — the license +/// permits subsetting; full font binaries stay out of the repo). The +/// subset keeps the format-4 unicode cmap, real `glyf` outlines, and +/// the full font's truthful `maxp` declaration (584 points / 84 +/// contours — past the old Latin-sized budgets, inside the CJK-sized +/// ones), so registration exercises the real gate and rendering inks +/// real Han ideograph outlines, deterministically, on every CI host. +const cjk_receipt_bytes = @embedFile("testdata/fonts/NotoSansSC-Receipt.ttf"); + +/// 你好世界 — "Hello, world". +const cjk_receipt_text = "\u{4F60}\u{597D}\u{4E16}\u{754C}"; + +/// 中文字体 — "Chinese font": four ideographs the subsetted fixture +/// face deliberately does NOT map (the subset carries exactly the +/// receipt string's four glyphs plus notdef; the test pins the gap +/// with glyphIndex() == 0 before relying on it). Rendered with the +/// SAME registered face, this string can only draw four notdef boxes +/// — the self-calibrating tofu baseline the receipt compares against. +const cjk_uncovered_text = "\u{4E2D}\u{6587}\u{5B57}\u{4F53}"; + +fn cjkAppView(ui: *FontApp.Ui, model: *const FontAppModel) FontApp.Ui.Node { + return ui.column(.{ .gap = 8, .padding = 12 }, .{ + ui.text(.{}, if (model.show_uncovered_cjk) cjk_uncovered_text else cjk_receipt_text), + }); +} + +test "the Chinese receipt: a scaffold-shaped app registers a CJK face and renders real glyphs, not tofu" { + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + // The scaffold shape: fonts declared on Options.fonts, tokens + // pointing the typography face slot at the registered id, Chinese + // text in the view. + const fonts = [_]FontApp.FontRegistration{.{ + .id = registered_font_id, + .name = "NotoSansSC-Receipt.ttf", + .ttf = cjk_receipt_bytes, + }}; + var options = fontAppOptions(&fonts); + options.view = cjkAppView; + const app_state = try std.testing.allocator.create(FontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = FontApp.init(std.testing.allocator, .{}, options); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + try std.testing.expectEqual(@as(usize, 0), harness.runtime.dispatchErrors().len); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.registeredCanvasFontCount()); + + // Per-glyph receipt: every ideograph resolves to its own real glyph + // in the registered face (the bundled face maps NONE of them — the + // exact gap the registration closes) and rasterizes with nonzero + // ink at body and headline sizes. + const face = harness.runtime.registeredCanvasFontFace(registered_font_id).?; + const InkCounter = struct { + covered: usize = 0, + pub fn pixel(self: *@This(), x: i32, y: i32, coverage: f32) void { + _ = x; + _ = y; + if (coverage > 0) self.covered += 1; + } + }; + var seen_glyphs: [4]u16 = undefined; + var index: usize = 0; + var glyph_count: usize = 0; + while (index < cjk_receipt_text.len) : (glyph_count += 1) { + const len = try std.unicode.utf8ByteSequenceLength(cjk_receipt_text[index]); + const codepoint = try std.unicode.utf8Decode(cjk_receipt_text[index .. index + len]); + index += len; + + try std.testing.expectEqual(@as(u16, 0), canvas.font_ttf.geist_regular.glyphIndex(codepoint)); + const glyph = face.glyphIndex(codepoint); + try std.testing.expect(glyph != 0); + // cmap distinguishes the codepoints — four ideographs, four + // distinct glyphs, never one shared fallback shape. + for (seen_glyphs[0..glyph_count]) |seen| try std.testing.expect(seen != glyph); + seen_glyphs[glyph_count] = glyph; + + for ([_]f32{ 16, 40 }) |size| { + const scale = size / face.units_per_em; + var builder = canvas.vector.PathBuilder(2048){}; + try face.glyphOutline(glyph, .{ .a = scale, .b = 0, .c = 0, .d = -scale, .tx = 4, .ty = size }, &builder); + try std.testing.expect(builder.slice().len > 0); + var counter = InkCounter{}; + try canvas.vector.fillPath( + builder.slice(), + canvas.Affine.identity(), + .nonzero, + canvas.vector.default_tolerance, + .{ .x0 = 0, .y0 = 0, .x1 = 64, .y1 = 64 }, + &counter, + ); + try std.testing.expect(counter.covered > 0); + } + } + try std.testing.expectEqual(@as(usize, 4), glyph_count); + + // Layout measures the ideographs with the registered face's own + // advances, so measured line breaking agrees with the inked glyphs. + const provider = harness.runtime.textMeasureProvider().?; + try std.testing.expect(provider.measureWidth(registered_font_id, 16.0, cjk_receipt_text) > 0); + + // End-to-end pixels through the deterministic reference renderer — + // the same path Windows and Linux present. The registered face + // draws the string as real outlines; the same view under the + // bundled face can only draw notdef boxes, so the two screenshots + // MUST differ. That difference is the receipt: the Chinese text on + // screen came from the registered face, not tofu. + const registered_shot = try fontFixtureScreenshot(harness, std.testing.allocator, registered_font_id); + defer std.testing.allocator.free(registered_shot); + const tofu_shot = try fontFixtureScreenshot(harness, std.testing.allocator, canvas.default_sans_font_id); + defer std.testing.allocator.free(tofu_shot); + var nonblank = false; + var pixel: usize = 0; + while (pixel + 4 <= registered_shot.len) : (pixel += 4) { + if (registered_shot[pixel + 3] != 0 and (registered_shot[pixel] != 0 or registered_shot[pixel + 1] != 0 or registered_shot[pixel + 2] != 0)) { + nonblank = true; + break; + } + } + try std.testing.expect(nonblank); + try std.testing.expect(!std.mem.eql(u8, registered_shot, tofu_shot)); + + // Self-calibrating tofu control: the bundled-face comparison above + // proves the pixels changed with the face, not that they are real + // ideographs — a renderer that wrongly resolved every ideograph to + // the REGISTERED face's notdef glyph and inked that glyph's own + // outline would still differ from the bundled shot (a different + // face's fallback pixels) and still pass nonblank. Render the same + // view with the same registered face showing a string the fixture + // face genuinely does not cover — first pinning that gap per + // codepoint — so the control shot IS this face's + // everything-uncovered rendering through the identical pipeline. + // Had the receipt string resolved to notdef, the two shots would + // match: same face, same per-glyph fallback, same advances. + // Differing proves the receipt pixels are real ideograph outlines, + // not any face's fallback. + var uncovered_index: usize = 0; + while (uncovered_index < cjk_uncovered_text.len) { + const len = try std.unicode.utf8ByteSequenceLength(cjk_uncovered_text[uncovered_index]); + const codepoint = try std.unicode.utf8Decode(cjk_uncovered_text[uncovered_index .. uncovered_index + len]); + uncovered_index += len; + try std.testing.expectEqual(@as(u16, 0), face.glyphIndex(codepoint)); + } + try app_state.dispatch(&harness.runtime, 1, .show_uncovered_cjk); + const uncovered_shot = try fontFixtureScreenshot(harness, std.testing.allocator, registered_font_id); + defer std.testing.allocator.free(uncovered_shot); + try std.testing.expect(!std.mem.eql(u8, registered_shot, uncovered_shot)); +} + +// ------------------- late registration re-measures installed layouts + +/// 你好 SDK — mixed CJK + Latin, chosen so the measured width MUST move +/// when the fixture face joins late. Pure-CJK width would not move: the +/// bundled estimator's East Asian wide fallback charges 1.0 em and the +/// fixture's ideographs really advance 1.0 em. The Latin tail is what +/// moves — the estimator charges Geist's own sub-em ASCII advances, +/// while the fixture face (subsetted to four ideographs plus notdef) +/// answers every ASCII codepoint with its 1.0 em notdef advance, the +/// documented no-cascade fallback registered faces take for uncovered +/// codepoints. +const late_mixed_text = "\u{4F60}\u{597D} SDK"; + +const late_window_label = "late-panel"; +const late_window_canvas_label = "late-panel-canvas"; + +fn lateFontAppView(ui: *FontApp.Ui, model: *const FontAppModel) FontApp.Ui.Node { + _ = model; + // A row lays text out at its MEASURED intrinsic width (a column's + // default cross alignment stretches children to the column width), + // so this widget's frame is a direct function of what the + // measurement seam answers for the string. + return ui.row(.{ .gap = 8, .padding = 12 }, .{ + ui.text(.{}, late_mixed_text), + }); +} + +fn lateFontAppWindows(model: *const FontAppModel, scratch: *FontApp.WindowsScratch) []const FontApp.WindowDescriptor { + _ = model; + scratch.windows[0] = .{ + .label = late_window_label, + .canvas_label = late_window_canvas_label, + .title = "Late", + .width = 240, + .height = 200, + }; + return scratch.windows[0..1]; +} + +fn lateFontAppWindowView(ui: *FontApp.Ui, model: *const FontAppModel, window_label: []const u8) FontApp.Ui.Node { + std.debug.assert(std.mem.eql(u8, window_label, late_window_label)); + return lateFontAppView(ui, model); +} + +/// The laid-out frame width of the view's one text widget. +fn lateTextFrameWidth(runtime: *core.Runtime, window_id: platform.WindowId, label: []const u8) !f32 { + const layout = try runtime.canvasWidgetLayout(window_id, label); + for (layout.nodes) |node| { + if (node.widget.kind == .text and std.mem.eql(u8, node.widget.text, late_mixed_text)) return node.widget.frame.width; + } + return error.TestUnexpectedResult; +} + +test "a face registered after install re-measures every installed surface's layout" { + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + // No declared fonts: the app installs measuring registered_font_id + // (the tokens' face slot) through the bundled estimator, the exact + // pre-registration state the fonts page's late-registration promise + // starts from. A declared secondary window pins the multi-surface + // half of the promise. + var options = fontAppOptions(&.{}); + options.view = lateFontAppView; + options.windows_fn = lateFontAppWindows; + options.window_view = lateFontAppWindowView; + const app_state = try std.testing.allocator.create(FontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = FontApp.init(std.testing.allocator, .{}, options); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + + // The always-declared panel window opened during the installing + // rebuild's window reconcile; its own first frame installs its tree. + const panel_id = blk: { + var buffer: [platform.max_windows]platform.WindowInfo = undefined; + for (harness.runtime.listWindows(&buffer)) |info| { + if (std.mem.eql(u8, info.label, late_window_label)) break :blk info.id; + } + return error.TestUnexpectedResult; + }; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .window_id = panel_id, + .label = late_window_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + + const main_before = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + const panel_before = try lateTextFrameWidth(&harness.runtime, panel_id, late_window_canvas_label); + try std.testing.expectEqual(main_before, panel_before); + + // Late registration through the runtime seam — the embedder path the + // fonts page documents under the `Options.fonts` sugar. + try harness.runtime.registerCanvasFont(registered_font_id, cjk_receipt_bytes); + + // The registration requested a frame for every open surface; ONE + // arriving frame (the main canvas's here — arrival order is the + // platform's) must re-measure every installed surface, not just the + // surface whose frame landed. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 2, + .timestamp_ns = 3_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 0), harness.runtime.dispatchErrors().len); + + const main_after = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + const panel_after = try lateTextFrameWidth(&harness.runtime, panel_id, late_window_canvas_label); + // Both surfaces now hold frames measured with the registered face's + // own advances — the widths moved, and moved identically. + try std.testing.expect(@abs(main_after - main_before) > 1); + try std.testing.expect(@abs(panel_after - panel_before) > 1); + try std.testing.expectEqual(main_after, panel_after); + + // The re-measured width IS the face-aware seam's answer scaled into + // the frame, not merely a different guess: the face measures the + // string wider than the estimator did (1.0 em notdef per ASCII + // codepoint versus Geist's sub-em advances), so the frame grew. + try std.testing.expect(main_after > main_before); +} + +// ------------------------ late registration adopts only on success + +/// Model for the failed-rebuild retry test below: `row_count` extra rows +/// join the measured text, so the test can push one rebuild past the +/// per-view widget budget and then heal it. +const RetryFontModel = struct { + row_count: usize = 0, + + pub fn rows(model: *const RetryFontModel, arena: std.mem.Allocator) []const usize { + const out = arena.alloc(usize, model.row_count) catch return &.{}; + for (out, 0..) |*slot, index| slot.* = index; + return out; + } +}; +const RetryFontMsg = union(enum) { noop }; +const RetryFontApp = ui_app_model.UiApp(RetryFontModel, RetryFontMsg); + +fn retryFontUpdate(model: *RetryFontModel, msg: RetryFontMsg) void { + _ = model; + _ = msg; +} + +fn retryFontTokens(model: *const RetryFontModel) canvas.DesignTokens { + _ = model; + var tokens = canvas.DesignTokens{}; + tokens.typography.font_id = registered_font_id; + return tokens; +} + +fn retryRowKey(index: *const usize) canvas.UiKey { + return canvas.uiKey(@as(u64, index.*)); +} + +fn retryRow(ui: *RetryFontApp.Ui, index: *const usize) RetryFontApp.Ui.Node { + return ui.text(.{}, ui.fmt("Row {d}", .{index.*})); +} + +fn retryFontView(ui: *RetryFontApp.Ui, model: *const RetryFontModel) RetryFontApp.Ui.Node { + // The measured text rides in a row (intrinsic width, like + // lateFontAppView above) so its frame moves when the registered + // face's advances replace the estimator's. + return ui.column(.{ .gap = 2 }, .{ + ui.row(.{ .gap = 8, .padding = 12 }, .{ ui.text(.{}, late_mixed_text) }), + ui.each(model.rows(ui.arena), retryRowKey, retryRow), + }); +} + +test "a failed late-font rebuild leaves the count unadopted so the next healthy frame retries" { + // The failing rebuild teaches through std.log; without lowering the + // level the warning would fail the build runner's stderr check. + const saved_log_level = std.testing.log_level; + std.testing.log_level = .err; + defer std.testing.log_level = saved_log_level; + + const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + + const app_state = try std.testing.allocator.create(RetryFontApp); + defer std.testing.allocator.destroy(app_state); + app_state.* = RetryFontApp.init(std.testing.allocator, .{}, .{ + .name = "ui-app-font-retry", + .scene = font_app_scene, + .canvas_label = font_app_canvas_label, + .tokens_fn = retryFontTokens, + .update = retryFontUpdate, + .view = retryFontView, + }); + defer app_state.deinit(); + const app = app_state.app(); + try harness.start(app); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + try std.testing.expect(app_state.installed); + const width_before = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + + // Production policy: dispatch degrades errors instead of dying — + // the policy under which a rebuild that adopted the font count + // BEFORE succeeding would strand stale layouts marked font-current, + // never retried. + harness.runtime.dispatch_error_policy = .degrade; + + // Grow the model past the per-view widget budget (mutated directly + // — no dispatch, so no rebuild yet), then register a face late so + // the next frame's late-registration rebuild is the failing one. + app_state.model.row_count = core.max_canvas_widget_nodes_per_view + 40; + try harness.runtime.registerCanvasFont(registered_font_id, cjk_receipt_bytes); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 2, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.dispatchErrors().len); + // The failed rebuild must NOT adopt the count: the mismatch it + // leaves behind is exactly what the next presented frame reads as + // its retry trigger. + try std.testing.expect(app_state.fonts_built_count != harness.runtime.registeredCanvasFontCount()); + + // Heal the model; the next presented frame observes the mismatch, + // rebuilds every surface, and only then adopts. + app_state.model.row_count = 0; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = font_app_canvas_label, + .size = geometry.SizeF.init(240, 200), + .scale_factor = 1, + .frame_index = 3, + .timestamp_ns = 3_000_000, + .nonblank = true, + } }); + try std.testing.expectEqual(@as(usize, 1), harness.runtime.dispatchErrors().len); + try std.testing.expectEqual(harness.runtime.registeredCanvasFontCount(), app_state.fonts_built_count); + + // The retried rebuild re-measured with the registered face, not + // merely repainted: the face answers the mixed string wider than + // the estimator did (1.0 em notdef per ASCII codepoint), so the + // text widget's frame grew. + const width_after = try lateTextFrameWidth(&harness.runtime, 1, font_app_canvas_label); + try std.testing.expect(width_after > width_before); +} + test "ui app fonts option surfaces the glyph-budget refusal as a teaching error" { const harness = try TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(240, 200) }); defer harness.destroy(std.testing.allocator); diff --git a/src/runtime/canvas_fonts.zig b/src/runtime/canvas_fonts.zig index 51af7377c..59994c6b3 100644 --- a/src/runtime/canvas_fonts.zig +++ b/src/runtime/canvas_fonts.zig @@ -75,6 +75,34 @@ pub const max_registered_canvas_font_bytes = canvas_limits.max_registered_canvas pub const CanvasFontEntry = struct { id: canvas.FontId = 0, bytes: []const u8 = &.{}, + /// The host-side unregistration owner — the platform's + /// `unregister_gpu_surface_font_fn` and its context, captured from + /// the services in effect when THIS registration was pushed to the + /// host. Captured precisely because `Runtime.options` is public and + /// mutable while registration and teardown can be a whole runtime + /// lifetime apart: the registration must be returned to the host + /// that received it, so `Runtime.deinit` unregisters through this + /// captured pair and mutating `options.platform` on a live runtime + /// retargets nothing (the `owned_allocator` identity-freeze + /// doctrine, applied to the host seam — a deinit that read the live + /// options would unregister against whatever platform the option + /// points at by teardown time, stranding the original host's + /// descriptor and caches). Null when the platform at registration + /// time had no unregister seam: it retained nothing to return. + host_unregister_fn: ?*const fn (context: ?*anyopaque, id: u64, token: u64) anyerror!void = null, + host_unregister_context: ?*anyopaque = null, + /// The host's ownership token for THIS registration, returned by + /// `registerGpuSurfaceFont` and passed back through the owner above + /// at deinit. Host font state is per-process while ids are only + /// permanent per-runtime, so a later runtime may re-register this + /// entry's id (last wins, the documented lifecycle); the host + /// removes an id's state only while its current registration still + /// carries the presented token, which keeps an older runtime's + /// deinit from tearing down a newer runtime's live face. 0 when the + /// platform returned no token (a stateless accept, or no register + /// seam at all): nothing was installed for this registration, and + /// an unregister carrying 0 removes nothing. + host_registration_token: u64 = 0, }; /// Placeholder measure fn for the runtime's font-aware provider field @@ -151,16 +179,38 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { // exact silent fallback this seam forbids. Platforms without // host-side text may lack the seam (`UnsupportedService`): // the engine measures with the parsed face and inks it - // through the reference renderer, so nothing is lost. - self.options.platform.services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { - error.UnsupportedService => { - if (self.options.platform.services.measure_text_fn != null) return error.FontHostRegistrationUnsupported; + // through the reference renderer, so nothing is lost. ONE + // services read serves both the sync and the captured return + // path below, so the host that hears the registration is the + // host the entry's unregister owner names. + const services = self.options.platform.services; + // The returned token is the host's ownership handle for THIS + // registration (0 when the host retained nothing); it rides + // the entry beside the captured owner so deinit can tell the + // host which registration to remove — see + // `CanvasFontEntry.host_registration_token`. + const host_token: u64 = services.registerGpuSurfaceFont(.{ .id = id, .ttf = pooled }) catch |err| switch (err) { + error.UnsupportedService => blk: { + if (services.measure_text_fn != null) return error.FontHostRegistrationUnsupported; + break :blk 0; }, else => return err, }; self.canvas_font_faces[index] = face; - self.canvas_font_entries[index] = .{ .id = id, .bytes = pooled }; + // The entry carries its own host unregistration owner, + // captured now (see `CanvasFontEntry.host_unregister_fn`): + // `options.platform` is publicly mutable, and the teardown + // return must land on the host that received this + // registration, never on whatever platform the option holds + // by `Runtime.deinit` time. + self.canvas_font_entries[index] = .{ + .id = id, + .bytes = pooled, + .host_unregister_fn = services.unregister_gpu_surface_font_fn, + .host_unregister_context = services.context, + .host_registration_token = host_token, + }; self.canvas_font_count = index + 1; // Bind the font-aware measure provider on first registration. // The runtime address is stable from here on (registration @@ -250,7 +300,12 @@ pub fn RuntimeCanvasFonts(comptime Runtime: type) type { /// A face joined the registry: force every gpu_surface view to /// re-render its next frame (text referencing the id may already /// be retained) and request frames so the repaint is not gated on - /// other input — the image-registry choreography. + /// other input — the image-registry choreography. Re-rendering + /// alone re-inks RETAINED geometry; installed UiApps complete the + /// late-registration story by comparing the registered count on + /// each presented frame and rebuilding every surface so layout + /// re-measures with the new face (ui_app.zig, + /// `rebuildForRegisteredFonts`). fn noteCanvasFontsChanged(self: *Runtime) void { // A new face changes what the measurement seam answers for // its id (host providers just learned the face; the engine diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 30f69e6c0..49978fa49 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -461,9 +461,14 @@ pub const Runtime = struct { /// Release the runtime's heap-owned on-demand storage (registered /// canvas font bytes, registered canvas image slot buffers, and /// adopted media-surface texture buffers, allocated from the - /// init-frozen `owned_allocator`) and disarm the - /// media-surface wake bindings (a producer thread must never wake a - /// dead host). Ends the runtime's life: parsed faces, atlas keys, + /// init-frozen `owned_allocator`), return each registered font's + /// host-side registration (through the unregister owner captured + /// into the entry at registration time — host font state is + /// per-process, this runtime's ids are not, and live `options` may + /// no longer name the host that received the registration), and + /// disarm the media-surface wake bindings (a producer + /// thread must never wake a dead host). Ends the runtime's life: + /// parsed faces, atlas keys, /// measure providers, adopted textures, and the resource slices /// built over them are invalid past this point. Process-lifetime /// embeddings (the app runner) may skip it — the default @@ -474,6 +479,30 @@ pub const Runtime = struct { pub fn deinit(self: *Runtime) void { self.disarmMediaSurfaceWakes(); for (self.canvas_font_entries[0..self.canvas_font_count]) |entry| { + // Return the HOST side of the registration before the bytes: + // registration pushed the face to platforms with host-side + // text (macOS installs a CoreText descriptor plus caches, + // keyed by an id that is only permanent per-RUNTIME while + // that host state is per-process), so a deinit that freed + // only the Zig-side bytes would leave an embedder cycling + // runtimes growing host font state for the process lifetime. + // Through the entry's owner captured at registration time, + // never live `options`: the option is publicly mutable, and + // an embedder that swapped the platform after registering + // must see this land on the host that holds the + // registration, not on the current platform or a null seam + // (the `owned_allocator` identity discipline two lines + // down, applied to the host). Best-effort by design — + // deinit cannot fail, and a null owner means the platform + // at registration time retained nothing to return. The + // entry's ownership token rides along so the host removes + // ONLY this runtime's registration: a later runtime may + // have re-registered the id (last wins), and presenting a + // stale token is a no-op accept — the newer runtime's live + // face survives this deinit. + if (entry.host_unregister_fn) |host_unregister_fn| { + host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {}; + } // Through the frozen identity, never `options.allocator`: // an embedder may have swapped the public option since these // bytes were made, and a free must hit the allocator that diff --git a/src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf b/src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf new file mode 100644 index 000000000..66942d152 Binary files /dev/null and b/src/runtime/testdata/fonts/NotoSansSC-Receipt.ttf differ diff --git a/src/runtime/testdata/fonts/OFL.txt b/src/runtime/testdata/fonts/OFL.txt new file mode 100644 index 000000000..79a917be1 --- /dev/null +++ b/src/runtime/testdata/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 1831808d5..7ef2779ae 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -739,6 +739,16 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// register would then fail `FontIdInUse` and bury the real /// error). fonts_registered: bool = false, + /// The runtime's registered-font count the installed trees last + /// measured against. Registration is permanent with no + /// unregister, so the count IS the runtime's fonts generation: + /// a mismatch on a presented frame means a face joined the + /// registry AFTER the trees were built (late registration + /// through `runtime.registerCanvasFont` — `Options.fonts` lands + /// before the installing build), and every installed surface + /// must rebuild so layout re-measures with the new face + /// (`rebuildForRegisteredFonts`). + fonts_built_count: usize = 0, /// Exactly-once guard for `Options.init_fx`, independent of /// `installed` so a failed install rebuild cannot rerun it. init_fx_ran: bool = false, @@ -1250,14 +1260,20 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// Whether a rebuild must push its tokens into the runtime's /// stored copy. Derived tokens can change with any model or /// appearance input, so they always re-emit. Static tokens are - /// fixed by the app, but the runtime stamps the surface scale - /// onto them (`effectiveTokens`), so a stored copy holding a - /// stale scale re-emits too — hairlines re-snap after a move - /// between monitors — while ordinary rebuilds keep skipping the - /// redundant emission. + /// fixed by the app, but the runtime stamps two live values onto + /// them: the surface scale (`effectiveTokens` — a stored copy + /// holding a stale scale re-emits so hairlines re-snap after a + /// move between monitors) and the text-measure provider + /// (`tokensWithTextMeasure` — the FIRST font registration binds + /// the runtime's font-aware provider on platforms without host + /// measurement, and a stored copy still measuring with the + /// estimator would keep display-list text layout on + /// pre-registration metrics). Ordinary rebuilds keep skipping + /// the redundant emission. fn rebuildEmitsTokens(self: *const Self, runtime: *Runtime, window_id: platform.WindowId, canvas_label: []const u8, tokens: canvas.DesignTokens) bool { if (self.derivesTokens()) return true; const stored = runtime.canvasWidgetDesignTokens(window_id, canvas_label) catch return true; + if (!std.meta.eql(stored.text_measure, tokens.text_measure)) return true; return stored.pixel_snap.scale != tokens.pixel_snap.scale; } @@ -2949,6 +2965,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe _ = try runtime.emitCanvasWidgetDisplayList(frame_event.window_id, self.options.canvas_label, runtime.tokensWithTextMeasure(self.effectiveTokens())); } self.installed = true; + // The installing rebuild measured with everything the + // registry holds right now (declared fonts registered + // above, embedder registrations before install): adopt + // the count so only faces joining AFTER this build + // trigger the late-registration rebuild. + self.fonts_built_count = runtime.registeredCanvasFontCount(); self.startMarkupWatch(runtime); self.installStatusItem(runtime); } else if (@abs(self.pixel_snap_scale - scale) > 0.001) { @@ -2977,6 +2999,17 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // job. self.canvas_size = frame_event.size; try self.rebuild(runtime, frame_event.window_id); + } else if (runtime.registeredCanvasFontCount() != self.fonts_built_count) { + // A face joined the registry after install (late + // registration through the runtime seam). The runtime + // already invalidated measurement caches and requested + // frames for every open surface (noteCanvasFontsChanged), + // but repainting retained geometry only re-inks text + // measured with the OLD seam answers: honoring the fonts + // doc's promise — every open surface re-measures — means + // rebuilding so layout and the re-emitted display lists + // charge the registered face's advances. + try self.rebuildForRegisteredFonts(runtime); } else if (self.options.web_panes != null) { // Re-snap the webview panes each presented frame: a shell // relayout that stomped a pane frame also invalidated the @@ -3031,10 +3064,39 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // stale bounds. slot.canvas_size = frame_event.size; try self.rebuildWindowSlot(runtime, slot); + } else if (runtime.registeredCanvasFontCount() != self.fonts_built_count) { + // Late registration, first observed on a secondary + // window's frame (registration requests frames for every + // open surface, and arrival order is the platform's): + // the same all-surfaces rebuild as the main canvas — + // the count is app-level, so whichever surface's frame + // lands first re-measures all of them. + try self.rebuildForRegisteredFonts(runtime); } try self.presentFrame(runtime, frame_event, slot.canvasLabel(), installing); } + /// A face joined the runtime's font registry after this app's + /// trees were built: rebuild EVERY installed surface — main + /// canvas and declared secondary windows — so widget frames and + /// baked text runs re-measure against the registered face, then + /// adopt the count. Rebuild re-emits each surface's display list + /// (`rebuildEmitsTokens` treats a text-measure provider change + /// as an emit reason), so the repaint the runtime already + /// requested draws re-measured geometry, not re-inked stale + /// frames. + fn rebuildForRegisteredFonts(self: *Self, runtime: *Runtime) anyerror!void { + try self.rebuildAllViews(runtime); + // Adopt the count only AFTER the rebuild succeeded: + // production dispatch degrades errors, so a failed rebuild + // (widget budget, allocator pressure, a secondary window's + // emit) that had already adopted the count would mark stale + // layouts as font-current and never retry. Left unadopted, + // the error leaves the count mismatched and the next + // presented frame retries the rebuild. + self.fonts_built_count = runtime.registeredCanvasFontCount(); + } + /// Present the planned canvas frame: GPU packet when the platform /// has a packet presenter (macOS/Metal — unchanged), otherwise the /// CPU reference-rendered pixel path (`presentGpuSurfacePixels`, diff --git a/tools/native-sdk/markup.zig b/tools/native-sdk/markup.zig index 2afdbb47c..17427fe76 100644 --- a/tools/native-sdk/markup.zig +++ b/tools/native-sdk/markup.zig @@ -632,7 +632,8 @@ fn usage() void { \\follows its closure; a file that is all templates is a \\valid component file), font coverage (literal text outside the \\bundled face renders as tofu boxes on reference paths - the error - \\names the character; use icons or plain words), and accessibility + \\names the character; register a covering font and bind the text + \\from the model, or use icons or plain words), and accessibility \\(unnamed interactive controls, icon-only controls without labels, \\and role misuse are errors - a screen reader user is blocked; \\unnamed images and redundant labels are warnings).