From 6b1440fdb4e379405e02c9663d59139dcd41afd6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 12:01:04 -0500 Subject: [PATCH 1/5] - Move wrapping, alignment, intrinsic width, and paged rich-text runs into compiled TypeScript. - Preserve native measurements, complete layout parity, and copied ABI ownership. --- build.zig | 4 + build/app.zig | 1 + packages/core/src/paragraph_layout.ts | 192 +++++++++++++++++ packages/core/src/runtime_policy.ts | 1 + packages/core/src/view_frontend.ts | 1 + packages/core/test/paragraph_layout.test.ts | 71 +++++++ packages/core/test/view_frontend.test.ts | 2 +- src/primitives/canvas/text_spans.zig | 205 ++++++++++++++++++- src/primitives/canvas/tokens.zig | 1 + src/primitives/canvas/widget_metrics.zig | 1 + src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 3 + tests/ts-core/paragraph_layout_e2e_tests.zig | 117 +++++++++++ tests/ts-core/widget_metric_e2e_tests.zig | 1 + 14 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/paragraph_layout.ts create mode 100644 packages/core/test/paragraph_layout.test.ts create mode 100644 tests/ts-core/paragraph_layout_e2e_tests.zig diff --git a/build.zig b/build.zig index 83e52f98a..5a560fbd8 100644 --- a/build.zig +++ b/build.zig @@ -1355,6 +1355,7 @@ pub fn build(b: *std.Build) void { native_api_tests.addFileArg(b.path("packages/core/test/chart_plans.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/indicator_plans.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/effect_plans.test.ts")); + native_api_tests.addFileArg(b.path("packages/core/test/paragraph_layout.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/component_composition.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/view_frontend.test.ts")); native_api_tests.has_side_effects = true; @@ -1378,6 +1379,7 @@ pub fn build(b: *std.Build) void { b.step("test-ts-widget-paint-walk", "Compare complete compiled retained paint traversal with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_paint_walk).step); b.step("test-ts-control-geometry", "Compare complete compiled control and scroll geometry with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.control_geometry).step); b.step("test-ts-control-content", "Compare compiled control content and editing chrome with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.control_content).step); + b.step("test-ts-paragraph-layout", "Compare complete compiled paragraph layout and font measurement continuations").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.widget_metrics.root_module, "ts-paragraph-layout-tests", &.{ "compiled widget metric paragraphs", "paragraph copied transport" })).step); b.step("test-ts-widget-metrics", "Compare complete compiled widget metrics and leaf dimensions with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_metrics).step); b.step("test-ts-surface-recipes", "Compare compiled surface chrome recipes with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.surface_recipes).step); b.step("test-ts-widget-presentation", "Compare complete compiled retained presentation with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_presentation).step); @@ -2516,6 +2518,7 @@ pub fn build(b: *std.Build) void { addTestStep(b, "test-json", "Run JSON primitive tests", json_tests); addTestStep(b, "test-app-runner-window-placement", "Run app-runner window placement decision tests", app_runner_window_placement_tests); addTestStep(b, "test-canvas", "Run canvas display list tests", canvas_tests); + addTestStep(b, "test-paragraph-transport", "Reject malformed copied paragraph plans", filteredTestArtifact(b, canvas_tests.root_module, "paragraph-transport-tests", &.{"paragraph copied transport"})); addTestStep(b, "test-desktop", "Run Native SDK framework tests", desktop_tests); addTestStep(b, "test-component-consumers", "Verify menu pins recovery fallback and virtual-scroll consumers", filteredTestArtifact(b, desktop_mod, "component-consumer-tests", &.{ "runtime.ui_app_tests.test.a declared context menu presents", @@ -5435,6 +5438,7 @@ fn externalCoreFixtureModule( stage_run.addFileInput(b.path("packages/core/src/view_frontend.ts")); stage_run.addFileInput(b.path("packages/core/src/view_components.ts")); stage_run.addFileInput(b.path("packages/core/src/runtime_policy.ts")); + stage_run.addFileInput(b.path("packages/core/src/paragraph_layout.ts")); stage_run.addFileInput(b.path("packages/core/src/control_appearance.ts")); stage_run.addFileInput(b.path("packages/core/src/component_construction.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_motion.ts")); diff --git a/build/app.zig b/build/app.zig index e2d0257d6..a78f54e7c 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1241,6 +1241,7 @@ fn tsCoreStage( stage_run.addFileInput(dep.path("packages/core/src/view_frontend.ts")); stage_run.addFileInput(dep.path("packages/core/src/view_components.ts")); stage_run.addFileInput(dep.path("packages/core/src/runtime_policy.ts")); + stage_run.addFileInput(dep.path("packages/core/src/paragraph_layout.ts")); stage_run.addFileInput(dep.path("packages/core/src/control_appearance.ts")); stage_run.addFileInput(dep.path("packages/core/src/component_construction.ts")); stage_run.addFileInput(dep.path("packages/core/src/widget_motion.ts")); diff --git a/packages/core/src/paragraph_layout.ts b/packages/core/src/paragraph_layout.ts new file mode 100644 index 000000000..850df9929 --- /dev/null +++ b/packages/core/src/paragraph_layout.ts @@ -0,0 +1,192 @@ +// Paragraph layout continuation. The host measures only the requested raw +// span slice. Every cursor, word, line, alignment and capacity decision lives +// in this copied packet; no host pointer or compiler frame is retained. +function nscvParagraphLayout(request: Uint8Array): Uint8Array { + if (request.length < 192 || request[0] !== 53 || request[1]! > 1 || request[2] !== 1 || request[3]! > 2 || request[4]! > 2 || request[5]! > 1 || request[6]! > 1 || request[7]! > 2) + throw new Error("invalid paragraph header"); + const out = request.slice(), w = new DataView(out.buffer); + const u = (at: number): number => w.getUint32(at, true); + const f = (at: number): number => w.getFloat32(at, true); + const put = (at: number, value: number): void => { w.setUint32(at, value, true); }; + const float = (at: number, value: number): void => { w.setFloat32(at, value, true); }; + const sourceCount = u(8), count = Math.min(sourceCount, 32), capacity = u(12), first = u(16), data = u(20); + const pieces = 192 + sourceCount * 32, runs = pieces + 32 * 16; + if (data !== runs + capacity * 32 || u(24) !== out.length || data > out.length) + throw new Error("invalid paragraph storage"); + let endOfText = data; + for (let s = 0; s < sourceCount; s++) { + const at = 192 + s * 32; + if (u(at) !== endOfText || u(at + 4) > out.length - endOfText || u(at + 16) > 1) + throw new Error("invalid paragraph span"); + endOfText += u(at + 4); + for (let p = at + 20; p < at + 32; p++) if (out[p] !== 0) throw new Error("invalid paragraph reserved byte"); + } + if (endOfText !== out.length || u(56) > capacity || u(60) > u(56) || u(96) > 32 || u(112) > u(96) || u(72) > 1 || u(76) > 1 || u(40) > 10) + throw new Error("invalid paragraph continuation"); + if (out[5] !== (sourceCount > 32 ? 1 : 0)) throw new Error("invalid paragraph truncation flag"); + for (let at = 172; at < 184; at++) if (out[at] !== 0) throw new Error("invalid paragraph reserved byte"); + const length = (s: number): number => { if (s >= count) throw new Error("invalid paragraph span index"); return u(196 + s * 32); }; + const byte = (s: number, p: number): number => { if (p >= length(s)) throw new Error("invalid paragraph byte index"); return out[u(192 + s * 32) + p]!; }; + const range = (s: number, a: number, b: number): void => { if (s >= count || a > b || b > length(s)) throw new Error("invalid paragraph range"); }; + const cursor = (s: number, p: number): void => { + if (s > count || (s === count ? p !== 0 : p > length(s))) throw new Error("invalid paragraph cursor"); + }; + const savedPhase = u(40); + if (savedPhase === 10) { + if (out[6] !== 0 || out[7] !== 0) throw new Error("invalid paragraph initial status"); + for (let at = 44; at < 192; at++) if (out[at] !== 0) throw new Error("invalid paragraph initial state"); + for (let at = pieces; at < data; at++) if (out[at] !== 0) throw new Error("invalid paragraph initial storage"); + } else { + if (out[6] !== 1 || out[7] !== 1) throw new Error("paragraph measurement reply missing"); + if (savedPhase !== 1 && savedPhase !== 3 && savedPhase !== 6 && savedPhase !== 8) throw new Error("invalid paragraph reply phase"); + cursor(u(44), u(48)); cursor(u(100), u(104)); cursor(u(152), u(156)); + if (u(160) > u(156) || u(52) > out.length || u(144) !== 0) throw new Error("invalid paragraph extent"); + if (u(84) !== u(88)) range(u(80), u(84), u(88)); + for (let n = 0; n < u(96); n++) range(u(pieces + n * 16), u(pieces + n * 16 + 4), u(pieces + n * 16 + 8)); + for (let n = 0; n < u(56); n++) { + const at = runs + n * 32, start = u(at + 4), size = u(at + 8), line = u(at + 12); + range(u(at), start, start + size); + if (size === 0 || line < first || line > u(52) || line >= Math.min(4294967295, first + 128) || u(at + 28) !== 0) + throw new Error("invalid paragraph run"); + } + range(u(128), u(132), u(136)); + if (savedPhase === 1 && (u(128) !== u(44) || u(132) !== u(48))) throw new Error("invalid paragraph whitespace reply"); + if (savedPhase === 3 && (u(96) >= 32 || u(128) !== u(100) || u(132) !== u(104))) throw new Error("invalid paragraph word reply"); + if (savedPhase === 6) { + if (u(112) >= u(96)) throw new Error("invalid paragraph piece cursor"); + const at = pieces + u(112) * 16; + if (u(128) !== u(at) || u(116) < u(at + 4) || u(116) > u(120) || u(120) >= u(at + 8) || u(132) !== u(116) || u(136) <= u(120) || u(136) > u(at + 8)) + throw new Error("invalid paragraph prefix reply"); + } + if (savedPhase === 8 && (u(128) !== u(152) || u(132) !== u(160) || u(136) !== u(156))) throw new Error("invalid paragraph intrinsic reply"); + if ((out[1] === 1) !== (savedPhase === 8)) throw new Error("invalid paragraph reply mode"); + } + const next = (s: number, start: number, offset: number, limit: number): number => { + let cursor = offset; + while (cursor > start && cursor < limit && (byte(s, cursor) & 0xc0) === 0x80) cursor--; + const lead = byte(s, cursor), size = (lead & 0x80) === 0 ? 1 : (lead & 0xe0) === 0xc0 ? 2 : (lead & 0xf0) === 0xe0 ? 3 : (lead & 0xf8) === 0xf0 ? 4 : 1; + const result = Math.min(limit, cursor + size); + return result <= offset ? Math.min(limit, offset + 1) : result; + }; + const clearPending = (): void => { put(84, 0); put(88, 0); float(92, 0); }; + const place = (s: number, start: number, end: number, width: number): void => { + range(s, start, end); + if (start === end) return; + const line = u(52), n = u(56); + if (line >= first) { + const at = runs + (n - 1) * 32; + if (n > u(60) && u(at) === s && u(at + 12) === line && u(at + 4) + u(at + 8) === start) { + put(at + 8, u(at + 8) + end - start); float(at + 20, Math.fround(f(at + 20) + width)); + } else if (n >= capacity || line >= Math.min(4294967295, first + 128)) put(76, 1); + else { + const at = runs + n * 32; + put(at, s); put(at + 4, start); put(at + 8, end - start); put(at + 12, line); + float(at + 16, f(64)); float(at + 20, width); + float(at + 24, Math.fround(f(188) + Math.fround(Math.fround(line) * f(184)))); put(at + 28, 0); put(56, n + 1); + } + } + const pen = Math.fround(f(64) + width); float(64, pen); float(68, nscvShellMax(f(68), pen)); put(72, 1); + }; + const flush = (): void => { const s = u(80), a = u(84), b = u(88), width = f(92); clearPending(); if (a !== b) place(s, a, b, width); }; + const align = (): void => { + if (out[4] === 0 || !Number.isFinite(f(36))) return; + const extra = Math.fround(f(36) - f(64)); if (extra <= 0) return; + const dx = out[4] === 1 ? Math.fround(extra * 0.5) : extra; + for (let n = u(60); n < u(56); n++) float(runs + n * 32 + 16, Math.fround(f(runs + n * 32 + 16) + dx)); + }; + const lineBreak = (): void => { clearPending(); align(); put(60, u(56)); put(52, u(52) + 1); float(64, 0); put(72, 0); }; + const ask = (phase: number, s: number, start: number, end: number): Uint8Array => { + range(s, start, end); put(40, phase); put(128, s); put(132, start); put(136, end); float(140, 0); out[6] = 0; out[7] = 1; return out; + }; + let phase = u(40); + if (phase === 10) { + let scale = 1; + for (let s = 0; s < sourceCount; s++) { const v = f(204 + s * 32); scale = Math.max(scale, Number.isFinite(v) && v > 0 ? v : 1); } + float(188, Math.fround(f(32) * scale)); + float(184, f(28) > 0 ? f(28) : Math.fround(f(188) * 1.25)); + if (out[3] === 0 || !(f(36) > 0) || !Number.isFinite(f(36))) float(36, Infinity); + put(76, out[5]!); phase = out[1] === 1 ? 7 : 0; + } + if (phase === 1 || phase === 3 || phase === 6 || phase === 8) { + if (out[6] !== 1 || out[7] !== 1) throw new Error("paragraph measurement reply missing"); + range(u(128), u(132), u(136)); + const width = f(140); + if (phase === 1) { + flush(); put(80, u(128)); put(84, u(132)); put(88, u(136)); float(92, width); put(48, u(136)); phase = 0; + } else if (phase === 3) { + const at = pieces + u(96) * 16; + if (u(96) >= 32) throw new Error("paragraph word overflow"); + put(at, u(128)); put(at + 4, u(132)); put(at + 8, u(136)); float(at + 12, width); + put(96, u(96) + 1); float(108, Math.fround(f(108) + width)); put(104, u(136)); phase = 2; + } else if (phase === 6) { + const overflow = Math.fround(f(64) + width) > Math.fround(f(36) + 0.015625); + if (overflow) { + if (u(120) === u(116) && u(72) === 0) { put(120, u(136)); float(124, width); } + phase = 9; + } else { put(120, u(136)); float(124, width); phase = 5; } + } else { + float(164, Math.fround(f(164) + width)); + if (u(156) < length(u(152))) { + if (byte(u(152), u(156)) === 10) { float(168, nscvShellMax(f(168), f(164))); float(164, 0); } + put(156, u(156) + 1); put(160, u(156)); + } else { put(152, u(152) + 1); put(156, 0); put(160, 0); } + phase = 7; + } + out[6] = 0; out[7] = 0; + } + while (true) { + if (phase === 7) { + const s = u(152); + if (s >= count) { float(68, nscvShellMax(f(168), f(164))); break; } + let cursor = u(156); + while (cursor < length(s) && byte(s, cursor) !== 10 && byte(s, cursor) !== 13) cursor++; + put(156, cursor); return ask(8, s, u(160), cursor); + } + if (phase === 0) { + const s = u(44), offset = u(48); + if (s >= count) { clearPending(); align(); const n = u(52) + (u(72) !== 0 || u(56) > u(60) || u(52) === 0 ? 1 : 0); put(144, n); float(148, Math.fround(Math.fround(n) * f(184))); break; } + if (offset >= length(s)) { put(44, s + 1); put(48, 0); continue; } + const b = byte(s, offset); + if (b === 10) { lineBreak(); put(48, offset + 1); continue; } + if (b === 13) { put(48, offset + 1); continue; } + if (b === 32 || b === 9) { + let end = offset; + while (end < length(s) && (byte(s, end) === 32 || byte(s, end) === 9)) end++; + if (u(72) !== 0 || u(208 + s * 32) === 1) return ask(1, s, offset, end); + put(48, end); continue; + } + put(96, 0); put(112, 0); put(100, s); put(104, offset); float(108, 0); phase = 2; + } else if (phase === 2) { + const s = u(100), offset = u(104); + if (s < count && u(96) < 32) { + if (offset >= length(s)) { put(100, s + 1); put(104, 0); continue; } + const b = byte(s, offset); + if (b !== 10 && b !== 32 && b !== 9) { + let end = offset; + while (end < length(s) && byte(s, end) !== 10 && byte(s, end) !== 13 && byte(s, end) !== 32 && byte(s, end) !== 9) end++; + return ask(3, s, offset, end); + } + } + if (out[3] === 1 && u(72) !== 0 && Math.fround(Math.fround(f(64) + f(92)) + f(108)) > Math.fround(f(36) + 0.015625)) lineBreak(); else flush(); + put(112, 0); phase = 4; + } else if (phase === 4) { + const p = u(112); + if (p >= u(96)) { put(44, u(100)); put(48, u(104)); phase = 0; continue; } + const at = pieces + p * 16, s = u(at), a = u(at + 4), b = u(at + 8), width = f(at + 12); + range(s, a, b); + if (a !== b && Math.fround(f(64) + width) > Math.fround(f(36) + 0.015625)) { put(116, a); put(120, a); float(124, 0); phase = 5; } + else { place(s, a, b, width); put(112, p + 1); } + } else if (phase === 5) { + const at = pieces + u(112) * 16, s = u(at), limit = u(at + 8); + if (u(120) < limit) return ask(6, s, u(116), next(s, u(at + 4), u(120), limit)); + phase = 9; + } else if (phase === 9) { + const at = pieces + u(112) * 16, s = u(at), limit = u(at + 8); + if (u(120) === u(116)) { lineBreak(); phase = 5; continue; } + place(s, u(116), u(120), f(124)); put(116, u(120)); + if (u(116) < limit) { lineBreak(); put(120, u(116)); float(124, 0); phase = 5; } + else { put(112, u(112) + 1); phase = 4; } + } else throw new Error("invalid paragraph phase"); + } + put(40, 0); out[6] = 0; out[7] = 2; return out; +} diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index 85916164a..4e159eb8c 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -245,6 +245,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 50) return nscvChartPlans(request); if (request[0] === 51) return nscvIndicatorPlans(request); if (request[0] === 52) return nscvEffectPlans(request); + if (request[0] === 53) return nscvParagraphLayout(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { diff --git a/packages/core/src/view_frontend.ts b/packages/core/src/view_frontend.ts index 473c80b33..3d875db9d 100644 --- a/packages/core/src/view_frontend.ts +++ b/packages/core/src/view_frontend.ts @@ -936,6 +936,7 @@ function compileViewFunction(source: string, contract: ViewContract, options: Vi const viewPrelude = "\n// Portable Native components compiled beside the committed model.\n" + readFileSync(new URL("./view_components.ts", import.meta.url), "utf8") + readFileSync(new URL("./runtime_policy.ts", import.meta.url), "utf8") + + readFileSync(new URL("./paragraph_layout.ts", import.meta.url), "utf8") + readFileSync(new URL("./control_appearance.ts", import.meta.url), "utf8") + readFileSync(new URL("./component_construction.ts", import.meta.url), "utf8") + readFileSync(new URL("./widget_motion.ts", import.meta.url), "utf8") + diff --git a/packages/core/test/paragraph_layout.test.ts b/packages/core/test/paragraph_layout.test.ts new file mode 100644 index 000000000..e4efb0627 --- /dev/null +++ b/packages/core/test/paragraph_layout.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +const source = ["runtime_policy", "paragraph_layout"].map(name => readFileSync(new URL(`../src/${name}.ts`, import.meta.url), "utf8")).join("\n"); +const { nscvParagraphLayout: plan } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport {nscvParagraphLayout};").toString("base64")}`); +function packet(text: string, width = 30, capacity = 160, first = 0): Uint8Array { + const bytes = new TextEncoder().encode(text), data = 192 + 32 + 512 + capacity * 32; + const b = new Uint8Array(data + bytes.length), w = new DataView(b.buffer); + b.set([53, 0, 1, 1, 0, 0]); + for (const [at, x] of [[8, 1], [12, capacity], [16, first], [20, data], [24, b.length], [40, 10], [192, data], [196, bytes.length]]) w.setUint32(at!, x!, true); + w.setFloat32(32, 10, true); w.setFloat32(36, width, true); b.set(bytes, data); return b; +} +function layout(b: Uint8Array): { result: DataView; calls: number[][] } { + const calls: number[][] = []; + for (let n = 0; n < 10000; n++) { + const before = b.slice(), out = plan(b); assert.deepEqual(b, before); + const w = new DataView(out.buffer); + if (out[7] === 2) return { result: w, calls }; + assert.equal(out[7], 1); + const span = w.getUint32(128, true), start = w.getUint32(132, true), end = w.getUint32(136, true); + calls.push([span, start, end]); w.setFloat32(140, (end - start) * 5, true); out[6] = 1; b = out; + } + throw new Error("continuation did not finish"); +} +test("paragraphs break words, retain source offsets and request exact prefixes", () => { + const { result: r, calls } = layout(packet("abc de")); + assert.deepEqual(calls, [[0, 0, 3], [0, 3, 4], [0, 4, 6]]); + assert.equal(r.getUint32(144, true), 1); assert.equal(r.getUint32(56, true), 1); + assert.equal(r.getFloat32(68, true), 30); assert.equal(r.getUint32(736 + 8, true), 6); + const small = layout(packet("abc de", 20)).result; + assert.equal(small.getUint32(144, true), 2); assert.equal(small.getUint32(56, true), 2); + assert.equal(small.getUint32(736 + 32 + 4, true), 4); +}); +test("paragraph alignment, pages and truncation preserve complete extents", () => { + const b = packet("abc\nde", 40); b[4] = 1; + const r = layout(b).result; + assert.equal(r.getFloat32(736 + 16, true), 12.5); assert.equal(r.getFloat32(736 + 32 + 16, true), 15); + const page = layout(packet("abc\nde", 40, 160, 1)).result; + assert.equal(page.getUint32(56, true), 1); assert.equal(page.getUint32(736 + 12, true), 1); + assert.equal(page.getFloat32(148, true), 25); + const empty = layout(packet("abc\nde", 40, 0)).result; + assert.equal(empty.getUint32(56, true), 0); assert.equal(empty.getUint32(76, true), 1); assert.equal(empty.getFloat32(148, true), 25); +}); +test("intrinsic paragraphs ignore CR presentation and carry lines across spans", () => { + const b = packet("ab\r\ncd\ref"); b[1] = 1; + const { result: r, calls } = layout(b); + assert.deepEqual(calls, [[0, 0, 2], [0, 3, 3], [0, 4, 6], [0, 7, 9]]); + assert.equal(r.getFloat32(68, true), 20); +}); +test("paragraph packets refuse malformed storage, spans and missing replies", () => { + assert.throws(() => plan(new Uint8Array(191)), /invalid/); + for (const [at, x] of [[0, 52], [1, 2], [2, 2], [3, 3], [4, 3], [6, 2], [7, 3], [208, 2], [216, 1]]) { + const b = packet("text"); b[at!] = x!; assert.throws(() => plan(b), /invalid/); + } + const query = plan(packet("text")); assert.throws(() => plan(query), /reply missing/); + const bad = packet("text"); new DataView(bad.buffer).setUint32(196, 9999, true); assert.throws(() => plan(bad), /invalid/); +}); +test("paragraph replies reject corrupt cursors pieces runs and initial state", () => { + for (const at of [44, 96, 172, 224]) { + const b = packet("a b"); b[at] = 1; assert.throws(() => plan(b), /invalid/); + } + const reply = plan(packet("a b")); reply[6] = 1; + for (const [at, x] of [[40, 5], [44, 2], [48, 4], [100, 2], [104, 4], [112, 33], [128, 1], [132, 1], [144, 1], [152, 2], [172, 1]]) { + const b = reply.slice(); new DataView(b.buffer).setUint32(at!, x!, true); assert.throws(() => plan(b), /invalid/); + } + const placed = plan(reply); placed[6] = 1; + for (const [at, x] of [[56, 161], [60, 2], [84, 1], [88, 4], [96, 33], [224, 1], [232, 4], [736, 1], [744, 0], [748, 129], [764, 1]]) { + const b = placed.slice(); new DataView(b.buffer).setUint32(at!, x!, true); assert.throws(() => plan(b), /invalid/, String(at)); + } +}); diff --git a/packages/core/test/view_frontend.test.ts b/packages/core/test/view_frontend.test.ts index 2d6c033cd..c46db8d79 100644 --- a/packages/core/test/view_frontend.test.ts +++ b/packages/core/test/view_frontend.test.ts @@ -269,7 +269,7 @@ test("the view frontend and compiled portable component bundle typecheck", () => }; // Production joins these policies in one module before compiling the view. const bundlePath = new URL("../src/view_components_typecheck.ts", import.meta.url).pathname; - const bundle = ["view_components.ts", "runtime_policy.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "widget_paint.ts", "widget_presentation.ts", "widget_paint_walk.ts", "control_geometry.ts", "control_content.ts", "surface_recipes.ts", "widget_metrics.ts", "intrinsic_measure.ts", "measurement_coordination.ts", "flow_measurement.ts", "layout_coordination.ts", "render_coordination.ts", "control_commands.ts", "control_payloads.ts", "control_primitives.ts", "leaf_plans.ts", "chart_plans.ts", "indicator_plans.ts", "effect_plans.ts", "stream_policy.ts"] + const bundle = ["view_components.ts", "runtime_policy.ts", "paragraph_layout.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "widget_paint.ts", "widget_presentation.ts", "widget_paint_walk.ts", "control_geometry.ts", "control_content.ts", "surface_recipes.ts", "widget_metrics.ts", "intrinsic_measure.ts", "measurement_coordination.ts", "flow_measurement.ts", "layout_coordination.ts", "render_coordination.ts", "control_commands.ts", "control_payloads.ts", "control_primitives.ts", "leaf_plans.ts", "chart_plans.ts", "indicator_plans.ts", "effect_plans.ts", "stream_policy.ts"] .map(name => readFileSync(new URL(`../src/${name}`, import.meta.url), "utf8")).join("\n"); const host = ts.createCompilerHost(options); const readSource = host.getSourceFile.bind(host); diff --git a/src/primitives/canvas/text_spans.zig b/src/primitives/canvas/text_spans.zig index dc60e74b1..1e26c5813 100644 --- a/src/primitives/canvas/text_spans.zig +++ b/src/primitives/canvas/text_spans.zig @@ -18,7 +18,8 @@ //! measurement seam carries the same font id, what is measured always //! matches what is drawn. //! -//! Everything here is allocation-free and deterministic. Capacity overflow +//! The native reference is allocation-free; compiled plans use copied transport +//! buffers. Both are deterministic. Capacity overflow //! never fails: layout truncates (dropping trailing runs) and reports it //! via `TextSpanLayout.truncated`. @@ -106,6 +107,8 @@ pub const TextSpanLayoutOptions = struct { /// Injected measurement; null falls back to the deterministic /// estimator (golden-stable). measure: ?*const text_metrics.TextMeasureProvider = null, + /// Portable paragraph owner; native supplies font measurements only. + paragraph_policy: ?*const fn ([]const u8, []u8) usize = null, }; /// One laid-out segment: a contiguous slice of one span on one line. @@ -177,6 +180,7 @@ pub fn textSpanLineHeight(spans: []const TextSpan, options: TextSpanLayoutOption /// width seam for widget sizing. Measures per-span with the span's font /// while carrying each line across span boundaries. pub fn textSpansIntrinsicWidth(spans: []const TextSpan, options: TextSpanLayoutOptions) f32 { + if (options.paragraph_policy != null) return compiledParagraph(spans, options, 0, &.{}, true).size.width; var width: f32 = 0; var max_width: f32 = 0; for (spans, 0..) |span, index| { @@ -462,6 +466,158 @@ pub fn textSpanRunVisibleSlice( }; } +fn paragraphWord(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} +fn paragraphPut(bytes: []u8, at: usize, value: usize) void { + std.mem.writeInt(u32, bytes[at..][0..4], std.math.cast(u32, value) orelse @panic("paragraph wire range"), .little); +} +fn paragraphFloat(bytes: []const u8, at: usize) f32 { + return @bitCast(paragraphWord(bytes, at)); +} +fn paragraphSetFloat(bytes: []u8, at: usize, value: f32) void { + std.mem.writeInt(u32, bytes[at..][0..4], @bitCast(value), .little); +} +fn paragraphRange(bytes: []const u8, span: usize, start: usize, end: usize) bool { + return span < @min(paragraphWord(bytes, 8), max_text_spans_per_paragraph) and start <= end and end <= paragraphWord(bytes, 196 + span * 32); +} +fn paragraphCursor(bytes: []const u8, span: usize, offset: usize) bool { + const count = @min(paragraphWord(bytes, 8), max_text_spans_per_paragraph); + return span <= count and (if (span == count) offset == 0 else offset <= paragraphWord(bytes, 196 + span * 32)); +} +/// Validate copied output before invoking any capability or rebasing a run. +fn paragraphResultValid(request: []const u8, result: []const u8) bool { + const count = paragraphWord(request, 8); + const capacity = paragraphWord(request, 12); + const first = paragraphWord(request, 16); + const pieces = 192 + @as(usize, count) * 32; + const runs = pieces + 512; + const data = paragraphWord(request, 20); + if (result.len != request.len or result[6] != 0 or result[7] < 1 or result[7] > 2 or + !std.mem.eql(u8, result[0..6], request[0..6]) or !std.mem.eql(u8, result[8..36], request[8..36]) or + !std.mem.eql(u8, result[192..pieces], request[192..pieces]) or !std.mem.eql(u8, result[data..], request[data..])) return false; + for (result[172..184]) |byte| if (byte != 0) return false; + const run_count = paragraphWord(result, 56); + const piece_count = paragraphWord(result, 96); + const line = paragraphWord(result, 52); + if (run_count > capacity or paragraphWord(result, 60) > run_count or piece_count > 32 or paragraphWord(result, 112) > piece_count or + paragraphWord(result, 72) > 1 or paragraphWord(result, 76) > 1 or line > result.len or + !paragraphCursor(result, paragraphWord(result, 44), paragraphWord(result, 48)) or + !paragraphCursor(result, paragraphWord(result, 100), paragraphWord(result, 104)) or + !paragraphCursor(result, paragraphWord(result, 152), paragraphWord(result, 156)) or paragraphWord(result, 160) > paragraphWord(result, 156)) return false; + if (paragraphWord(result, 84) != paragraphWord(result, 88) and !paragraphRange(result, paragraphWord(result, 80), paragraphWord(result, 84), paragraphWord(result, 88))) return false; + for (0..piece_count) |n| { + const at = pieces + n * 16; + if (!paragraphRange(result, paragraphWord(result, at), paragraphWord(result, at + 4), paragraphWord(result, at + 8))) return false; + } + var previous_line: usize = first; + for (0..run_count) |n| { + const at = runs + n * 32; + const start = paragraphWord(result, at + 4); + const length = paragraphWord(result, at + 8); + const run_line = paragraphWord(result, at + 12); + if (length == 0 or !paragraphRange(result, paragraphWord(result, at), start, @as(usize, start) + length) or + run_line < previous_line or run_line > line or @as(u64, run_line) >= @as(u64, first) + 128 or paragraphWord(result, at + 28) != 0) return false; + previous_line = run_line; + } + if (result[7] == 2) return paragraphWord(result, 40) == 0 and paragraphWord(result, 144) <= @as(u64, line) + 1 and + (request[1] == 1 or paragraphWord(result, 144) >= line) and (run_count == 0 or previous_line < paragraphWord(result, 144)); + if (paragraphWord(result, 144) != 0) return false; + const phase = paragraphWord(result, 40); + const s = paragraphWord(result, 128); + const start = paragraphWord(result, 132); + const end = paragraphWord(result, 136); + if (!paragraphRange(result, s, start, end) or (request[1] == 1) != (phase == 8)) return false; + // A repeated query with unchanged state cannot advance the source walk. + if (request[7] == 1 and std.mem.eql(u8, result[40..140], request[40..140]) and std.mem.eql(u8, result[144..], request[144..])) return false; + return switch (phase) { + 1 => s == paragraphWord(result, 44) and start == paragraphWord(result, 48), + 3 => piece_count < 32 and s == paragraphWord(result, 100) and start == paragraphWord(result, 104), + 6 => blk: { + const p = paragraphWord(result, 112); + if (p >= piece_count) break :blk false; + const at = pieces + @as(usize, p) * 16; + const prefix = paragraphWord(result, 120); + break :blk s == paragraphWord(result, at) and start == paragraphWord(result, 116) and + start >= paragraphWord(result, at + 4) and start <= prefix and prefix < end and end <= paragraphWord(result, at + 8); + }, + 8 => s == paragraphWord(result, 152) and start == paragraphWord(result, 160) and end == paragraphWord(result, 156), + else => false, + }; +} +/// Native owns the transport and caller storage. A copied continuation asks +/// for one raw font slice; only the portable owner schedules measurements, +/// chooses breaks and emits offset-based runs. +fn compiledParagraph(spans: []const TextSpan, options: TextSpanLayoutOptions, first_line: usize, runs_storage: []TextSpanRun, intrinsic: bool) TextSpanLayout { + const allocator = std.heap.page_allocator; + const count = spans.len; + const capacity = if (intrinsic) 0 else runs_storage.len; + const spans_size = std.math.mul(usize, count, 32) catch @panic("paragraph span capacity"); + const runs_size = std.math.mul(usize, capacity, 32) catch @panic("paragraph run capacity"); + const runs_at = std.math.add(usize, 192 + 32 * 16, spans_size) catch @panic("paragraph packet capacity"); + const data_at = std.math.add(usize, runs_at, runs_size) catch @panic("paragraph packet capacity"); + var total = data_at; + for (spans[0..count]) |span| total = std.math.add(usize, total, span.text.len) catch @panic("paragraph text capacity"); + if (total > std.math.maxInt(u32)) @panic("paragraph packet range"); + const request = allocator.alloc(u8, total) catch @panic("paragraph request allocation"); + defer allocator.free(request); + const result = allocator.alloc(u8, total) catch @panic("paragraph result allocation"); + defer allocator.free(result); + @memset(request, 0); + request[0..6].* = .{ 53, @intFromBool(intrinsic), 1, @intFromEnum(options.wrap), @intFromEnum(options.alignment), @intFromBool(spans.len > max_text_spans_per_paragraph) }; + paragraphPut(request, 8, count); + paragraphPut(request, 12, capacity); + paragraphPut(request, 16, @min(first_line, std.math.maxInt(u32))); + paragraphPut(request, 20, data_at); + paragraphPut(request, 24, total); + paragraphSetFloat(request, 28, options.line_height); + paragraphSetFloat(request, 32, options.size); + paragraphSetFloat(request, 36, options.max_width); + paragraphPut(request, 40, 10); + var at = data_at; + for (spans[0..count], 0..) |span, s| { + const base = 192 + s * 32; + paragraphPut(request, base, at); + paragraphPut(request, base + 4, span.text.len); + paragraphSetFloat(request, base + 12, span.scale); + paragraphPut(request, base + 16, @intFromBool(span.monospace)); + @memcpy(request[at..][0..span.text.len], span.text); + at += span.text.len; + } + // Bound the finite source walk, including up to 32 zero-length + // CR pieces per word, cluster retries and skipped lines. + const quota = std.math.mul(usize, total, 40) catch @panic("paragraph continuation range"); + var calls: usize = 0; + while (true) { + calls += 1; + if (calls > quota) @panic("paragraph continuation did not finish"); + @memset(result, 0xa5); + if (options.paragraph_policy.?(request, result) != total or !paragraphResultValid(request, result)) @panic("invalid paragraph continuation"); + if (result[7] == 2) break; + const phase = paragraphWord(result, 40); + if (phase != 1 and phase != 3 and phase != 6 and phase != 8) @panic("invalid paragraph measurement action"); + const s = paragraphWord(result, 128); + const start = paragraphWord(result, 132); + const end = paragraphWord(result, 136); + if (s >= count or start > end or end > spans[s].text.len) @panic("invalid paragraph measurement range"); + @memcpy(request, result); + paragraphSetFloat(request, 140, measureSpanSlice(spans[s], spans[s].text[start..end], options)); + request[6] = 1; + } + const run_count = paragraphWord(result, 56); + for (runs_storage[0..run_count], 0..) |*run, n| { + const base = runs_at + n * 32; + const s = paragraphWord(result, base); + const start = paragraphWord(result, base + 4); + const length = paragraphWord(result, base + 8); + const line = paragraphWord(result, base + 12); + if (s >= count or start > spans[s].text.len or length > spans[s].text.len - start or line < @min(first_line, std.math.maxInt(u32)) or + paragraphWord(result, base + 28) != 0) @panic("invalid paragraph emitted run"); + run.* = .{ .span_index = s, .text = spans[s].text[start..][0..length], .line_index = line, .x = paragraphFloat(result, base + 16), .width = paragraphFloat(result, base + 20), .baseline = paragraphFloat(result, base + 24), .size = textSpanSize(spans[s], options.size), .font_id = textSpanFontId(spans[s], options.typography) }; + } + return .{ .runs = runs_storage[0..run_count], .line_count = paragraphWord(result, 144), .line_height = paragraphFloat(result, 184), .size = geometry.SizeF.init(paragraphFloat(result, 68), paragraphFloat(result, 148)), .truncated = paragraphWord(result, 76) == 1 }; +} + const LayoutState = struct { spans: []const TextSpan, options: TextSpanLayoutOptions, @@ -619,6 +775,7 @@ fn layoutTextSpansUncached( first_line: usize, runs_storage: []TextSpanRun, ) TextSpanLayout { + if (options.paragraph_policy != null) return compiledParagraph(spans, options, first_line, runs_storage, false); var state = LayoutState{ .spans = spans, .options = options, @@ -740,6 +897,7 @@ const SpanWrapKey = struct { mono_font_id: FontId = 0, provider_context: usize = 0, provider_fn: usize = 0, + paragraph_policy: usize = 0, generation: u64 = 0, used: bool = false, }; @@ -822,6 +980,7 @@ fn spanWrapKey(spans: []const TextSpan, options: TextSpanLayoutOptions) SpanWrap .mono_font_id = options.typography.mono_font_id, .provider_context = @intFromPtr(provider.context), .provider_fn = @intFromPtr(provider.measure_fn), + .paragraph_policy = if (options.paragraph_policy) |p| @intFromPtr(p) else 0, .generation = text_measure_cache.textMeasureGeneration(), .used = true, }; @@ -840,6 +999,7 @@ fn spanWrapKeysEqual(a: SpanWrapKey, b: SpanWrapKey) bool { a.mono_font_id == b.mono_font_id and a.provider_context == b.provider_context and a.provider_fn == b.provider_fn and + a.paragraph_policy == b.paragraph_policy and a.generation == b.generation; } @@ -1547,3 +1707,46 @@ pub fn textSpanLinkCount(spans: []const TextSpan) usize { } return count; } + +test "paragraph copied transport rejects malformed ownership cursors and runs" { + var request: [741]u8 = @splat(0); + request[0..6].* = .{ 53, 0, 1, 1, 0, 0 }; + paragraphPut(&request, 8, 1); + paragraphPut(&request, 12, 1); + paragraphPut(&request, 20, 736); + paragraphPut(&request, 24, request.len); + paragraphPut(&request, 40, 10); + paragraphPut(&request, 192, 736); + paragraphPut(&request, 196, 5); + @memcpy(request[736..], "hello"); + var good = request; + good[7] = 1; + paragraphPut(&good, 40, 3); + paragraphPut(&good, 136, 5); + try std.testing.expect(paragraphResultValid(&request, &good)); + for ([_]usize{ 0, 6, 7, 8, 24, 28, 172, 192, 736 }) |at| { + var bad = good; + bad[at] +%= 3; + try std.testing.expect(!paragraphResultValid(&request, &bad)); + } + for ([_]usize{ 40, 44, 48, 56, 60, 72, 76, 96, 100, 104, 112, 128, 132, 136, 144, 152, 156, 160 }) |at| { + var bad = good; + paragraphPut(&bad, at, 999); + try std.testing.expect(!paragraphResultValid(&request, &bad)); + } + var done = good; + done[7] = 2; + paragraphPut(&done, 40, 0); + paragraphPut(&done, 56, 1); + paragraphPut(&done, 144, 1); + paragraphPut(&done, 712, 5); + try std.testing.expect(paragraphResultValid(&request, &done)); + for ([_]usize{ 704, 708, 712, 716, 732 }) |at| { + var bad = done; + paragraphPut(&bad, at, 999); + try std.testing.expect(!paragraphResultValid(&request, &bad)); + } + var repeated = good; + repeated[6] = 1; + try std.testing.expect(!paragraphResultValid(&repeated, &good)); +} diff --git a/src/primitives/canvas/tokens.zig b/src/primitives/canvas/tokens.zig index 8db0b4ff7..3102b7171 100644 --- a/src/primitives/canvas/tokens.zig +++ b/src/primitives/canvas/tokens.zig @@ -1934,6 +1934,7 @@ pub const DesignTokens = struct { widget_paint_walk_policy: ?*const fn ([]const u8, []u8) usize = null, control_geometry_policy: ?*const fn ([]const u8, []u8) usize = null, code_content_policy: ?*const fn ([]const u8, []u8) usize = null, + paragraph_policy: ?*const fn ([]const u8, []u8) usize = null, pub fn theme(options: ThemeOptions) DesignTokens { // The pack resolves the register (palette, control tables, and diff --git a/src/primitives/canvas/widget_metrics.zig b/src/primitives/canvas/widget_metrics.zig index 6423272fd..bcabeeeb9 100644 --- a/src/primitives/canvas/widget_metrics.zig +++ b/src/primitives/canvas/widget_metrics.zig @@ -115,6 +115,7 @@ pub fn widgetTextSpanLayoutOptions(widget: Widget, tokens: DesignTokens, max_wid .alignment = widget.text_alignment, .typography = tokens.typography, .measure = tokens.text_measure, + .paragraph_policy = tokens.paragraph_policy, }; } diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index bf83f7eea..86328c943 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -450,6 +450,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF stamped.composition_policy = core.nativeWindowPolicy; if (options.code_content_policy != null) @panic("TsUiApp owns code_content_policy - remove custom code content wiring"); stamped.code_content_policy = core.nativeWindowPolicy; + if (options.paragraph_policy != null) @panic("TsUiApp owns paragraph_policy - remove custom paragraph wiring"); + stamped.paragraph_policy = core.nativeWindowPolicy; if (options.chart_content_policy != null) @panic("TsUiApp owns chart_content_policy - remove custom chart content wiring"); stamped.chart_content_policy = core.nativeWindowPolicy; if (options.markdown_content_policy != null) @panic("TsUiApp owns markdown_content_policy - remove custom Markdown content wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 28ab63ed4..27e6bbca9 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -500,6 +500,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// builder copies descriptors into its existing generation arena. composition_policy: ?canvas.ComponentCompositionPolicy.Policy = null, code_content_policy: ?canvas.CodeContentPolicy.Policy = null, + paragraph_policy: ?*const fn ([]const u8, []u8) usize = null, chart_content_policy: ?canvas.ChartContentPolicy.Policy = null, markdown_content_policy: ?canvas.MarkdownContentPolicy.Policy = null, construction_policy: ?canvas.ComponentConstructionPolicy.Policy = null, @@ -2044,6 +2045,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.widget_paint_walk_policy = self.options.widget_paint_walk_policy; tokens.control_geometry_policy = self.options.control_geometry_policy; tokens.code_content_policy = self.options.code_content_policy; + tokens.paragraph_policy = self.options.paragraph_policy; return tokens; } const state = self.currentThemeState(); @@ -2107,6 +2109,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.widget_paint_walk_policy = self.options.widget_paint_walk_policy; tokens.control_geometry_policy = self.options.control_geometry_policy; tokens.code_content_policy = self.options.code_content_policy; + tokens.paragraph_policy = self.options.paragraph_policy; return tokens; } diff --git a/tests/ts-core/paragraph_layout_e2e_tests.zig b/tests/ts-core/paragraph_layout_e2e_tests.zig new file mode 100644 index 000000000..3bf92b212 --- /dev/null +++ b/tests/ts-core/paragraph_layout_e2e_tests.zig @@ -0,0 +1,117 @@ +//! Complete portable paragraphs, including measurement order and copied runs. +const std = @import("std"); +const sdk = @import("native_sdk"); +const c = sdk.canvas; +const core = @import("ts_persist_core"); +const exact = @import("component_construction_e2e_tests.zig").exact; +fn compare(spans: []const c.TextSpan, options: c.TextSpanLayoutOptions, first: usize, capacity: usize) !void { + var ar: [192]c.TextSpanRun = undefined; + var br: [192]c.TextSpanRun = undefined; + var compiled = options; + compiled.paragraph_policy = core.nativeWindowPolicy; + const a = c.text_spans.layoutTextSpansFromLine(spans, options, first, ar[0..capacity]); + const b = c.text_spans.layoutTextSpansFromLine(spans, compiled, first, br[0..capacity]); + try exact(a, b); + try exact(c.text_spans.textSpansIntrinsicWidth(spans, options), c.text_spans.textSpansIntrinsicWidth(spans, compiled)); + core.rt.frameReset(); + try exact(a, b); +} +test "compiled widget metric paragraphs preserve complete wrapping alignment paging and capacities" { + _ = core.initialModel(); + const texts = [_][]const u8{ "", "hello world", " lead\tspace tail ", "word\n\nline\n", "ab\r\ncd\ref", "Γ© πŸ™‚η•Œ\xff\x80\xc0!", "averylongunbrokenword" }; + for (texts) |text| { + const spans = [_]c.TextSpan{ .{ .text = text, .weight = .bold }, .{ .text = " suffix ", .italic = true, .scale = 1.25 }, .{ .text = "\tcode\n kept", .monospace = true } }; + for (std.enums.values(c.TextWrap)) |wrap| for (std.enums.values(c.TextAlign)) |alignment| for ([_]f32{ 0, 1, 12.25, 38, 96, std.math.inf(f32), std.math.nan(f32) }) |width| { + for ([_]usize{ 0, 1, 4, 160, 192 }) |capacity| for ([_]usize{ 0, 1, 3 }) |first| + try compare(&spans, .{ .size = 11, .wrap = wrap, .alignment = alignment, .max_width = width }, first, capacity); + }; + } +} +const Measure = struct { + const Call = struct { font: u64, size: f32, len: usize, hash: u64 }; + calls: [4096]Call = undefined, + len: usize = 0, + fn measure(context: ?*anyopaque, font: u64, size: f32, text: []const u8) f32 { + const self: *Measure = @ptrCast(@alignCast(context.?)); + self.calls[self.len] = .{ .font = font, .size = size, .len = text.len, .hash = std.hash.Wyhash.hash(0, text) }; + self.len += 1; + var width: f32 = 0; + for (text) |byte| width += @as(f32, @floatFromInt(byte % 7 + 1)) * size / 12; + return width; + } +}; +test "compiled widget metric paragraphs preserve exact provider calls and source ownership" { + _ = core.initialModel(); + const spans = [_]c.TextSpan{ .{ .text = "tight kerning \r\nlongwordη•ŒπŸ™‚" }, .{ .text = "next span", .weight = .bold, .scale = 2 }, .{ .text = " monospace\t", .monospace = true } }; + for ([_]usize{ 0, 2, 130 }) |first| { + var a: Measure = .{}; + var b: Measure = .{}; + var ap: c.TextMeasureProvider = .{ .context = &a, .measure_fn = Measure.measure }; + var bp: c.TextMeasureProvider = .{ .context = &b, .measure_fn = Measure.measure }; + var ar: [160]c.TextSpanRun = undefined; + var br: [160]c.TextSpanRun = undefined; + const ao: c.TextSpanLayoutOptions = .{ .size = 13.25, .max_width = 37.25, .alignment = .center, .measure = &ap }; + var bo = ao; + bo.measure = &bp; + bo.paragraph_policy = core.nativeWindowPolicy; + const al = c.text_spans.layoutTextSpansFromLine(&spans, ao, first, &ar); + const bl = c.text_spans.layoutTextSpansFromLine(&spans, bo, first, &br); + try exact(al, bl); + try exact(a.calls[0..a.len], b.calls[0..b.len]); + a.len = 0; + b.len = 0; + try exact(c.text_spans.textSpansIntrinsicWidth(&spans, ao), c.text_spans.textSpansIntrinsicWidth(&spans, bo)); + try exact(a.calls[0..a.len], b.calls[0..b.len]); + core.rt.frameReset(); + try exact(al, bl); + } +} +test "compiled widget metric paragraphs preserve all-span scale and bounded line pages" { + _ = core.initialModel(); + var spans: [34]c.TextSpan = @splat(.{ .text = "x\n" }); + spans[33].scale = 4; + try compare(&spans, .{ .size = 10 }, 0, 160); + const long = [_]c.TextSpan{.{ .text = "row\n" ** 260 }}; + for ([_]usize{ 0, 120, 128, 255, 260 }) |first| try compare(&long, .{ .size = 12, .max_width = 80 }, first, 192); +} +test "compiled widget metric paragraphs preserve exceptional size scale and line-height words" { + _ = core.initialModel(); + const words = [_]u32{ 0, 0x80000000, 0x3f800000, 0xbf800000, 0x7f800000, 0xff800000, 0x7fc00037, 1 }; + for (words) |size| for (words) |height| for (words) |scale| { + const spans = [_]c.TextSpan{.{ .text = "ab cd\n", .scale = @bitCast(scale) }}; + try compare(&spans, .{ .size = @bitCast(size), .line_height = @bitCast(height), .max_width = 12.25 }, 0, 160); + }; +} +var policy_calls: usize = 0; +fn trackedPolicy(request: []const u8, output: []u8) usize { + policy_calls += 1; + return core.nativeWindowPolicy(request, output); +} +test "compiled widget metric paragraphs keep retained cache owners distinct and rebase copied source" { + _ = core.initialModel(); + c.bumpTextMeasureGeneration(); + var trace: Measure = .{}; + const provider: c.TextMeasureProvider = .{ .context = &trace, .measure_fn = Measure.measure }; + const spans = [_]c.TextSpan{.{ .text = "unique cache owner words" }}; + var a: [160]c.TextSpanRun = undefined; + var b: [160]c.TextSpanRun = undefined; + const reference_options: c.TextSpanLayoutOptions = .{ .size = 12, .measure = &provider, .max_width = 60 }; + var compiled = reference_options; + compiled.paragraph_policy = trackedPolicy; + const expected = c.text_spans.layoutTextSpans(&spans, reference_options, &a); + policy_calls = 0; + const actual = c.text_spans.layoutTextSpans(&spans, compiled, &b); + try std.testing.expect(policy_calls > 0); + try exact(expected, actual); + core.rt.frameReset(); + var copied: ["unique cache owner words".len]u8 = undefined; + @memcpy(&copied, spans[0].text); + const fresh = [_]c.TextSpan{.{ .text = &copied }}; + trace.len = 0; + policy_calls = 0; + const hit = c.text_spans.layoutTextSpans(&fresh, compiled, &b); + try exact(expected, hit); + try std.testing.expectEqual(@as(usize, 0), trace.len); + try std.testing.expectEqual(@as(usize, 0), policy_calls); + for (hit.runs) |run| try std.testing.expect(@intFromPtr(run.text.ptr) >= @intFromPtr(&copied) and @intFromPtr(run.text.ptr) + run.text.len <= @intFromPtr(&copied) + copied.len); +} diff --git a/tests/ts-core/widget_metric_e2e_tests.zig b/tests/ts-core/widget_metric_e2e_tests.zig index 9d569391e..7c3d2241a 100644 --- a/tests/ts-core/widget_metric_e2e_tests.zig +++ b/tests/ts-core/widget_metric_e2e_tests.zig @@ -14,6 +14,7 @@ test { _ = @import("chart_plan_e2e_tests.zig"); _ = @import("indicator_plan_e2e_tests.zig"); _ = @import("effect_plan_e2e_tests.zig"); + _ = @import("paragraph_layout_e2e_tests.zig"); } fn owned(tokens: c.DesignTokens) c.DesignTokens { var t = tokens; From 7cf942da077d2cf774bdaff52154773966fa2459 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 12:24:32 -0500 Subject: [PATCH 2/5] - Preserve valid ELF unwind data by linking compiled archives at the final app link. - Complete paragraph transport fixtures and theme ownership expectations. --- build/app.zig | 20 +++++++++++++++----- src/primitives/canvas/text_spans.zig | 14 +++++++------- tests/ts-core/typography_e2e_tests.zig | 2 ++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/build/app.zig b/build/app.zig index a78f54e7c..e64374cbd 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1957,7 +1957,8 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A options.addOption(bool, "web_layer", web_layer); const options_mod = options.createModule(); - const app_mod = appModule(b, dep, target, app_optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config); + const defer_ts_archives = target.result.ofmt == .elf; + const app_mod = appModule(b, dep, target, app_optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config, !defer_ts_archives); // TypeScript app code and platform hosts are expensive Zig/Clang semantic // work but do not depend on primary markup bytes. Compile them once into // an object, then make the executable a link-only artifact over that @@ -1978,6 +1979,13 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A }); const link_mod = b.createModule(.{ .target = target, .optimize = app_optimize }); link_mod.addObject(app_code); + // ELF relocatable links can leave an archive's unwind terminator + // before later .eh_frame records. Keep the compiled archives on the + // final link, preserving unwind data and the cached app-code split. + if (defer_ts_archives) { + link_mod.addObjectFile(stage.archive); + if (stage.service_archive) |service_archive| link_mod.addObjectFile(service_archive); + } if (app_optimize == .Debug) link_mod.addObject(markupDataObject(b, target, app_optimize, stage.markup_c)); // Object dependencies propagate framework/system-library NAMES, but // Zig does not propagate the search paths or rpaths recorded on the @@ -2045,7 +2053,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A // the production app module feeds the cached app-code object and must not // absorb the separately linked markup data object. const test_app_mod = if (ts_stage != null or app_optimize != optimize) - appModule(b, dep, target, optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config) + appModule(b, dep, target, optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config, true) else app_mod; if (ts_stage) |stage| { @@ -2256,7 +2264,7 @@ fn exampleOptimizeMode(b: *std.Build, requested: ?std.builtin.OptimizeMode, defa }; } -fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_options: AppOptions, manifest_name: []const u8, options_mod: *std.Build.Module, ts_stage: ?TsCoreStage, relational_migrations: std.Build.LazyPath, app_config: AppManifestBuildConfig) *std.Build.Module { +fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_options: AppOptions, manifest_name: []const u8, options_mod: *std.Build.Module, ts_stage: ?TsCoreStage, relational_migrations: std.Build.LazyPath, app_config: AppManifestBuildConfig, link_ts_archives: bool) *std.Build.Module { const native_sdk_mod = nativeSdkModuleWithTerminal(b, dep, target, optimize, app_options.terminal_sessions); const runner_mod = b.createModule(.{ .root_source_file = dep.path("src/app_runner/root.zig"), @@ -2292,7 +2300,7 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv // The compiled-core archive links behind the staged mirror; the // toolchain's runtime needs libc. app_mod.link_libc = true; - app_mod.addObjectFile(stage.archive); + if (link_ts_archives) app_mod.addObjectFile(stage.archive); // Debug reads src/app.native only through the separately linked C // data object. Supplying this generated module in Debug would put // the authored root back into the app-code dependency graph even @@ -2306,7 +2314,9 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv } // The in-process service archive links beside it (distinct symbol // prefix; its runtime internals are localized). - if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive); + if (link_ts_archives) { + if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive); + } } if (app_config.sqlite_capability) { // `store` and the relational `sqlite` tier share this exact object. diff --git a/src/primitives/canvas/text_spans.zig b/src/primitives/canvas/text_spans.zig index 1e26c5813..d017e09a5 100644 --- a/src/primitives/canvas/text_spans.zig +++ b/src/primitives/canvas/text_spans.zig @@ -1709,22 +1709,22 @@ pub fn textSpanLinkCount(spans: []const TextSpan) usize { } test "paragraph copied transport rejects malformed ownership cursors and runs" { - var request: [741]u8 = @splat(0); + var request: [773]u8 = @splat(0); request[0..6].* = .{ 53, 0, 1, 1, 0, 0 }; paragraphPut(&request, 8, 1); paragraphPut(&request, 12, 1); - paragraphPut(&request, 20, 736); + paragraphPut(&request, 20, 768); paragraphPut(&request, 24, request.len); paragraphPut(&request, 40, 10); - paragraphPut(&request, 192, 736); + paragraphPut(&request, 192, 768); paragraphPut(&request, 196, 5); - @memcpy(request[736..], "hello"); + @memcpy(request[768..], "hello"); var good = request; good[7] = 1; paragraphPut(&good, 40, 3); paragraphPut(&good, 136, 5); try std.testing.expect(paragraphResultValid(&request, &good)); - for ([_]usize{ 0, 6, 7, 8, 24, 28, 172, 192, 736 }) |at| { + for ([_]usize{ 0, 6, 7, 8, 24, 28, 172, 192, 768 }) |at| { var bad = good; bad[at] +%= 3; try std.testing.expect(!paragraphResultValid(&request, &bad)); @@ -1739,9 +1739,9 @@ test "paragraph copied transport rejects malformed ownership cursors and runs" { paragraphPut(&done, 40, 0); paragraphPut(&done, 56, 1); paragraphPut(&done, 144, 1); - paragraphPut(&done, 712, 5); + paragraphPut(&done, 744, 5); try std.testing.expect(paragraphResultValid(&request, &done)); - for ([_]usize{ 704, 708, 712, 716, 732 }) |at| { + for ([_]usize{ 736, 740, 744, 748, 764 }) |at| { var bad = done; paragraphPut(&bad, at, 999); try std.testing.expect(!paragraphResultValid(&request, &bad)); diff --git a/tests/ts-core/typography_e2e_tests.zig b/tests/ts-core/typography_e2e_tests.zig index 5e516aa3a..c0d83070e 100644 --- a/tests/ts-core/typography_e2e_tests.zig +++ b/tests/ts-core/typography_e2e_tests.zig @@ -45,6 +45,7 @@ test "compiled model themes copy the complete register across frame and commit o reference.widget_paint_walk_policy = core.nativeWindowPolicy; reference.control_geometry_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; + reference.paragraph_policy = core.nativeWindowPolicy; try std.testing.expectEqualDeep(reference, snapshot); const large = app.model.large; core.rt.frameReset(); @@ -108,6 +109,7 @@ fn restamp(reference: *canvas.DesignTokens, actual: canvas.DesignTokens) void { reference.widget_paint_walk_policy = core.nativeWindowPolicy; reference.control_geometry_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; + reference.paragraph_policy = core.nativeWindowPolicy; } test "compiled themes retain forced scheme, accessibility and complete-register precedence" { const app = try Adapter.create(std.testing.allocator, .{}, .{ .name = "theme-coordination", .scene = .{}, .canvas_label = "canvas", .view = view, .theme_state_fn = forcedDark }); From a9c8fb85c7a379d23465ca28333de6f658c5c370 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 15:03:06 -0500 Subject: [PATCH 3/5] Move text run planning into TypeScript - Compile plain and shaped line, elision, caret, hit-test, and ink geometry decisions. - Preserve font capability ordering and copied caller-owned transport with complete native parity tests. --- build.zig | 3 + build/app.zig | 1 + packages/core/src/runtime_policy.ts | 1 + packages/core/src/text_run_layout.ts | 340 ++++++++++++++++++ packages/core/src/view_frontend.ts | 1 + packages/core/test/text_run_layout.test.ts | 87 +++++ packages/core/test/view_frontend.test.ts | 2 +- src/primitives/canvas/commands.zig | 6 +- src/primitives/canvas/layout_audit.zig | 1 + src/primitives/canvas/root.zig | 3 + src/primitives/canvas/text_layout.zig | 32 +- src/primitives/canvas/text_layout_hash.zig | 1 + src/primitives/canvas/text_layout_types.zig | 3 + src/primitives/canvas/text_run_policy.zig | 230 ++++++++++++ src/primitives/canvas/tokens.zig | 1 + src/primitives/canvas/widget_audit_policy.zig | 1 + src/primitives/canvas/widget_render.zig | 6 + src/primitives/canvas/widget_text_input.zig | 3 +- src/primitives/canvas/widget_text_select.zig | 1 + src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 3 + tests/ts-core/text_run_layout_e2e_tests.zig | 246 +++++++++++++ tests/ts-core/typography_e2e_tests.zig | 2 + tests/ts-core/widget_metric_e2e_tests.zig | 1 + 24 files changed, 972 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/text_run_layout.ts create mode 100644 packages/core/test/text_run_layout.test.ts create mode 100644 src/primitives/canvas/text_run_policy.zig create mode 100644 tests/ts-core/text_run_layout_e2e_tests.zig diff --git a/build.zig b/build.zig index 5a560fbd8..23b3a8b2b 100644 --- a/build.zig +++ b/build.zig @@ -1356,6 +1356,7 @@ pub fn build(b: *std.Build) void { native_api_tests.addFileArg(b.path("packages/core/test/indicator_plans.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/effect_plans.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/paragraph_layout.test.ts")); + native_api_tests.addFileArg(b.path("packages/core/test/text_run_layout.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/component_composition.test.ts")); native_api_tests.addFileArg(b.path("packages/core/test/view_frontend.test.ts")); native_api_tests.has_side_effects = true; @@ -1380,6 +1381,7 @@ pub fn build(b: *std.Build) void { b.step("test-ts-control-geometry", "Compare complete compiled control and scroll geometry with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.control_geometry).step); b.step("test-ts-control-content", "Compare compiled control content and editing chrome with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.control_content).step); b.step("test-ts-paragraph-layout", "Compare complete compiled paragraph layout and font measurement continuations").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.widget_metrics.root_module, "ts-paragraph-layout-tests", &.{ "compiled widget metric paragraphs", "paragraph copied transport" })).step); + b.step("test-ts-text-run-layout", "Compare compiled ordinary text lines, elision and caret geometry").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.widget_metrics.root_module, "ts-text-run-layout-tests", &.{ "compiled text runs", "compiled text bounds", "text run copied transport", "portable text planner context" })).step); b.step("test-ts-widget-metrics", "Compare complete compiled widget metrics and leaf dimensions with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_metrics).step); b.step("test-ts-surface-recipes", "Compare compiled surface chrome recipes with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.surface_recipes).step); b.step("test-ts-widget-presentation", "Compare complete compiled retained presentation with the native reference").dependOn(&b.addRunArtifact(ts_core_artifacts.widget_presentation).step); @@ -5439,6 +5441,7 @@ fn externalCoreFixtureModule( stage_run.addFileInput(b.path("packages/core/src/view_components.ts")); stage_run.addFileInput(b.path("packages/core/src/runtime_policy.ts")); stage_run.addFileInput(b.path("packages/core/src/paragraph_layout.ts")); + stage_run.addFileInput(b.path("packages/core/src/text_run_layout.ts")); stage_run.addFileInput(b.path("packages/core/src/control_appearance.ts")); stage_run.addFileInput(b.path("packages/core/src/component_construction.ts")); stage_run.addFileInput(b.path("packages/core/src/widget_motion.ts")); diff --git a/build/app.zig b/build/app.zig index e64374cbd..fe96c3066 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1242,6 +1242,7 @@ fn tsCoreStage( stage_run.addFileInput(dep.path("packages/core/src/view_components.ts")); stage_run.addFileInput(dep.path("packages/core/src/runtime_policy.ts")); stage_run.addFileInput(dep.path("packages/core/src/paragraph_layout.ts")); + stage_run.addFileInput(dep.path("packages/core/src/text_run_layout.ts")); stage_run.addFileInput(dep.path("packages/core/src/control_appearance.ts")); stage_run.addFileInput(dep.path("packages/core/src/component_construction.ts")); stage_run.addFileInput(dep.path("packages/core/src/widget_motion.ts")); diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index 4e159eb8c..2ad905b32 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -246,6 +246,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 51) return nscvIndicatorPlans(request); if (request[0] === 52) return nscvEffectPlans(request); if (request[0] === 53) return nscvParagraphLayout(request); + if (request[0] === 54) return nscvTextRunLayout(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { diff --git a/packages/core/src/text_run_layout.ts b/packages/core/src/text_run_layout.ts new file mode 100644 index 000000000..f68917721 --- /dev/null +++ b/packages/core/src/text_run_layout.ts @@ -0,0 +1,340 @@ +// Ordinary text planning uses copied continuations. Fonts, widths and +// advances are capabilities; cursor, overflow and geometry decisions are +// portable. No result borrows a compiler frame or a host pointer. +function nscvTextRunLayout(request: Uint8Array): Uint8Array { + if (request.length < 512 || request[0] !== 54 || request[1] !== 1 || request[2]! > 8 || request[3]! > 1 || request[4]! > 2 || request[5]! > 2 || request[6]! > 1 || request[7]! > 3) + throw new Error("invalid text run header"); + // Only the fixed continuation is returned. Immutable text/glyph facts + // stay in the caller-owned request rather than being copied per query. + const out = request.slice(0, 512), w = new DataView(out.buffer); + const facts = new DataView(request.buffer, request.byteOffset, request.byteLength); + const u = (at: number): number => w.getUint32(at, true); + const f = (at: number): number => (at < 512 ? w : facts).getFloat32(at, true); + const put = (at: number, value: number): void => { w.setUint32(at, value, true); }; + const float = (at: number, value: number): void => { w.setFloat32(at, value, true); }; + const add = (a: number, b: number): number => Math.fround(a + b); + const sub = (a: number, b: number): number => Math.fround(a - b); + const mul = (a: number, b: number): number => Math.fround(a * b); + const div = (a: number, b: number): number => Math.fround(a / b); + const min = (a: number, b: number): number => nscvShellMin(a, b); + const max = (a: number, b: number): number => nscvShellMax(a, b); + const mode = out[2]!, size = f(16), ox = f(20), oy = f(24), width = f(28); + const textLength = u(36), glyphCount = u(40), advancesAt = 512 + glyphCount * 32, textAt = advancesAt + textLength * 4; + if (u(8) !== textAt || u(12) !== request.length || textAt + textLength !== request.length || u(52) > 1 || u(44) > (glyphCount > 0 && mode === 0 ? glyphCount : textLength)) + throw new Error("invalid text run storage"); + const text = request.subarray(textAt), hasMeasure = (out[7]! & 1) !== 0, drawMeasure = (out[7]! & 2) !== 0; + for (let n = 0; n < glyphCount; n++) for (let at = 512 + n * 32 + 20; at < 512 + (n + 1) * 32; at++) if (request[at] !== 0) throw new Error("invalid text glyph reserved byte"); + if (u(356) > 1 || u(364) > 1) throw new Error("invalid text line flags"); + for (let at = 376; at < 512; at++) if (out[at] !== 0) throw new Error("invalid text reserved byte"); + if (out[3] === 0) { + for (let at = 80; at < 320; at++) if (out[at] !== 0) throw new Error("invalid initial text state"); + if (mode === 0 || mode === 1) for (let at = 320; at < 376; at++) if (out[at] !== 0) throw new Error("invalid initial text line"); + } + const glyph = (n: number, field: number): number => { if (n >= glyphCount) throw new Error("invalid text glyph index"); return f(512 + n * 32 + field); }; + const glyphWord = (n: number, field: number): number => { if (n >= glyphCount) throw new Error("invalid text glyph index"); return facts.getUint32(512 + n * 32 + field, true); }; + const advance = (n: number): number => max(mul(size, 0.25), glyph(n, 8)); + const seq = (byte: number): number => (byte & 128) === 0 ? 1 : (byte & 224) === 192 ? 2 : (byte & 240) === 224 ? 3 : (byte & 248) === 240 ? 4 : 1; + const snap = (offset: number): number => { let p = Math.min(offset, textLength); while (p > 0 && p < textLength && (text[p]! & 192) === 128) p--; return p; }; + const next = (offset: number): number => { const p = snap(offset); if (p >= textLength) return textLength; const end = Math.min(textLength, p + seq(text[p]!)); return end <= offset ? Math.min(textLength, offset + 1) : end; }; + const isBreak = (p: number): boolean => text[p] === 32 || text[p] === 9; + const scalarCount = (a: number, b: number): number => { let n = 0; while (a < b) { n++; a += Math.min(seq(text[a]!), b - a); } return n; }; + const scalarOffset = (a: number, b: number, count: number): number => { let n = 0; while (a < b && n < count) { a = Math.min(b, next(a)); n++; } return a; }; + // Exact u64 product/division for proportional glyph ranges, including + // packets whose valid index products exceed binary64's integer range. + const ratioIndex = (a: number, b: number, denominator: number, ceil: boolean): number => { + const al = a % 65536, ah = Math.floor(a / 65536), bl = b % 65536, bh = Math.floor(b / 65536); + const p0 = al * bl, p1 = ah * bl + al * bh + Math.floor(p0 / 65536); + const product: NscFlowInteger = { low: p0 % 65536 + (p1 % 65536) * 65536, high: ah * bh + Math.floor(p1 / 65536) }; + const result = nscvSemanticDivide(product, nscvFlowSmall(denominator)); + return result.quotient.low + result.quotient.high * 4294967296 + (ceil && (result.remainder.low !== 0 || result.remainder.high !== 0) ? 1 : 0); + }; + const range = (start: number, length: number): { start: number; end: number } => { + if (textLength === 0 || glyphCount === 0) return { start: 0, end: 0 }; + const end = Math.min(glyphCount, start + length), count = scalarCount(0, textLength); + let a = textLength, b = 0, explicit = length > 0 && start < glyphCount; + if (explicit) for (let n = start; n < end; n++) { + const size = glyphWord(n, 16); if (size === 0) { explicit = false; break; } + const at = glyphWord(n, 12), first = snap(at), last = snap(at + size); + a = Math.min(a, first); b = Math.max(b, last); + } + if (explicit) return { start: a, end: b }; + if (count === 0) return { start: 0, end: 0 }; + return { start: scalarOffset(0, textLength, Math.min(count, ratioIndex(start, count, glyphCount, false))), end: scalarOffset(0, textLength, Math.min(count, ratioIndex(end, count, glyphCount, true))) }; + }; + const glyphBreak = (n: number): boolean => { const r = range(n, 1); return r.start < r.end && isBreak(r.start); }; + const trim = (a: number, b: number): number => { let p = b; while (p > a && isBreak(p - 1)) p--; return p === a ? b : p; }; + const done = (): Uint8Array => { out[3] = 2; put(96, 0); put(100, 0); put(112, 0); return out; }; + const ask = (phase: number, kind: number, a: number, b: number): Uint8Array => { + if (a > b || b > textLength) throw new Error("invalid text capability range"); + put(96, phase); put(100, kind); put(104, a); put(108, b); put(112, 0); float(116, 0); out[3] = 1; return out; + }; + let phase = u(96); + if (out[3] === 0 ? phase !== 0 : u(112) !== 1) throw new Error("invalid text continuation reply"); + if (phase !== 0 && (u(100) < 1 || u(100) > 7 || u(104) > u(108) || u(108) > textLength || u(120) > 1)) throw new Error("invalid text continuation capability"); + if (phase !== 0 && !(mode === 0 && (phase === 1 || phase === 3 || phase === 11 || phase === 12 || phase === 14 || phase === 15 || phase === 17 || phase === 19 || phase === 21 || phase === 22 || phase === 24 || phase === 31 || phase === 32) || mode === 1 && (phase === 1 || phase === 3) || mode === 2 && phase === 40 || mode === 3 && (phase === 42 || phase === 43) || mode === 4 && (phase === 31 || phase === 32) || mode === 5 && phase === 50 || mode === 6 && (phase === 60 || phase === 61) || mode === 8 && (phase === 50 || phase === 62))) throw new Error("invalid text mode continuation"); + if (phase !== 0) { + const kind = phase === 1 || phase === 12 ? 3 : phase === 11 || phase === 15 || phase === 24 ? 5 : phase === 19 || phase === 21 ? 2 : phase === 31 ? 4 : phase === 42 ? drawMeasure ? 1 : 2 : phase === 60 || phase === 62 ? 6 : phase === 61 ? 7 : 1; + if (u(100) !== kind || (kind === 3 || kind === 4) && (u(104) !== 0 || u(108) !== textLength) || (kind === 5 || kind === 7) && (u(104) !== 0 || u(108) !== 0)) throw new Error("invalid text phase capability"); + } + const rawGlyphBounds = (start: number, length: number, baseline: number, height: number): void => { + const end = Math.min(glyphCount, start + length), firstX = glyph(start, 0); + let minX = 0, maxX = advance(start), minY = sub(baseline, size), maxY = add(minY, height); + for (let n = start; n < end; n++) { + const x = sub(glyph(n, 0), firstX), y = glyph(n, 4); + minX = min(minX, x); maxX = max(maxX, add(x, advance(n))); + minY = min(minY, sub(add(baseline, y), size)); maxY = max(maxY, add(add(baseline, y), mul(size, 0.25))); + } + float(336, add(ox, minX)); float(340, minY); float(344, max(0, sub(maxX, minX))); float(348, max(0, sub(maxY, minY))); + }; + const finishBounds = (): Uint8Array => { + if (mode === 4) return done(); + float(344, add(f(344), f(372))); + const limit = max(0, width); + if (!(limit <= 0 || f(344) >= limit)) { + const extra = sub(limit, f(344)), dx = out[5] === 0 ? 0 : out[5] === 1 ? mul(extra, 0.5) : extra; + float(336, add(f(336), dx)); float(340, add(f(340), 0)); + } + put(300, 1); return done(); + }; + const setLine = (ts: number, tl: number, gs: number, gl: number): void => { + put(320, ts); put(324, tl); put(328, gs); put(332, gl); + const height = f(32) > 0 ? f(32) : mul(size, 1.25), baseline = add(oy, mul(Math.fround(u(48)), height)); + float(352, baseline); float(348, height); + }; + const endPlain = (end: number): void => { + put(148, end); + if (mode === 1) { put(308, end); phase = 99; return; } + let cursor = end; + const finished = end >= textLength; + if (!finished) { if (text[cursor] === 10) cursor++; else while (out[4] === 1 && cursor < textLength && isBreak(cursor)) cursor++; } + put(288, finished ? u(44) : cursor); put(292, u(48) + 1); put(296, finished ? 1 : 0); + setLine(u(144), end - u(144), u(144), end - u(144)); phase = 10; + }; + const takeBreakWidth = (value: number): void => { + const p = u(152), n = next(p); if (isBreak(p)) { put(156, n); put(160, 1); } + const limit = width > 0 ? width : Infinity; + if (value > limit) endPlain(p === u(144) ? n : out[4] === 1 && u(160) !== 0 && u(156) > u(144) ? trim(u(144), u(156)) : p); + else { float(164, value); put(152, n); phase = 2; } + }; + const elided = (length: number, painted: number): void => { put(356, 1); put(360, length); float(184, painted); put(188, 1); phase = 30; }; + const takeElisionAdvance = (value: number): void => { + const p = u(152), n = next(p), total = add(f(164), value); float(164, total); + if (total <= sub(width, f(372))) { put(172, n); float(176, total); } + put(152, n); phase = 18; + }; + const lineRange = (): { start: number; end: number } => { const start = Math.min(u(320), textLength); return { start, end: Math.min(textLength, start + u(324)) }; }; + const explicitLine = (): boolean => { if (u(332) === 0 || u(328) >= glyphCount) return false; for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) if (glyphWord(n, 16) === 0) return false; return true; }; + const savedRawGlyphBounds = (): { dx: number; first: number } => { + const x = f(336), bh = f(348), saved = out.slice(336, 352); + rawGlyphBounds(u(328), u(332), f(352), bh); const rawX = f(336); + out.set(saved, 336); + return { dx: sub(x, rawX), first: glyph(u(328), 0) }; + }; + const glyphX = (n: number, first: number, dx: number): number => add(sub(add(ox, glyph(n, 0)), first), dx); + const inflate = (): void => { + const em = max(0, size), left = mul(em, 0.1), top = mul(em, 0.35); + float(336, sub(f(336), left)); float(340, sub(f(340), top)); + float(344, add(add(f(344), left), top)); float(348, add(add(f(348), top), left)); + }; + const unionInk = (x: number, y: number): void => { + if (u(120) === 0) return; + const bx = add(x, f(124)), by = sub(y, f(136)), bw = sub(f(132), f(124)), bh = sub(f(136), f(128)); + const emptyA = f(344) <= 0 || f(348) <= 0, emptyB = bw <= 0 || bh <= 0; + if (emptyA && emptyB) { for (let at = 336; at < 352; at += 4) float(at, 0); return; } + if (emptyB) return; + if (emptyA) { float(336, bx); float(340, by); float(344, bw); float(348, bh); return; } + const x0 = min(f(336), bx), y0 = min(f(340), by), x1 = max(add(f(336), f(344)), add(bx, bw)), y1 = max(add(f(340), f(348)), add(by, bh)); + float(336, x0); float(340, y0); float(344, sub(x1, x0)); float(348, sub(y1, y0)); + }; + while (true) { + if (phase === 99) return done(); + if (phase === 0 && (mode === 0 || mode === 1)) { + put(288, u(44)); put(292, u(48)); put(296, u(52)); + if (mode === 0 && u(52) !== 0) return done(); + put(144, u(44)); put(152, u(44)); + if (mode === 0 && glyphCount > 0) { + let start = u(44); while (out[4] === 1 && start < glyphCount && glyphBreak(start)) start++; + if (start >= glyphCount) { + put(296, 1); if (u(48) > 0) return done(); setLine(0, 0, 0, 0); put(292, 1); phase = 30; continue; + } + let end = glyphCount, index = start, total = 0, last = -1; + if (out[4] !== 0 && width > 0 && width !== Infinity) while (index < glyphCount) { + if (glyphBreak(index)) last = index; + const value = add(total, advance(index)); + if (value > width) { end = index === start ? index + 1 : out[4] === 1 && last > start ? last : index; break; } + total = value; index++; + } + const r = range(start, end - start); setLine(r.start, r.end - r.start, start, end - start); + put(288, end); put(292, u(48) + 1); put(296, 0); + if (!(out[4] === 0 && out[6] === 0 && width > 0 && width !== Infinity && end > start)) { phase = 30; continue; } + total = 0; for (let n = start; n < end; n++) total = add(total, advance(n)); + if (total <= add(width, 0.125)) { phase = 30; continue; } + return ask(24, 5, 0, 0); + } + if (textLength === 0) { endPlain(0); phase = mode === 1 ? 99 : 30; continue; } + if (out[4] === 0 || !(width > 0) || width === Infinity) { let end = u(144); while (end < textLength && text[end] !== 10) end++; endPlain(end); continue; } + if (hasMeasure) return ask(1, 3, 0, textLength); + phase = 2; + } + if (phase === 1) { put(168, u(120)); phase = 2; } + if (phase === 2) { + const p = u(152); if (p >= textLength || text[p] === 10) { endPlain(p); continue; } + if (u(168) !== 0) { takeBreakWidth(add(f(164), f(advancesAt + p * 4))); continue; } + return ask(3, 1, u(144), next(p)); + } + if (phase === 3) { takeBreakWidth(f(116)); continue; } + if (phase === 10) { + if (!(out[4] === 0 && out[6] === 0 && width > 0 && width !== Infinity && u(148) > u(144))) { phase = 30; continue; } + if (hasMeasure) return ask(12, 3, 0, textLength); + put(204, 2); return ask(11, 5, 0, 0); + } + if (phase === 12) { + if (u(120) !== 0) { put(204, 1); return ask(11, 5, 0, 0); } + return ask(14, 1, u(144), u(148)); + } + if (phase === 14) { + if (f(116) <= add(width, 0.125)) { float(184, f(116)); put(188, 1); phase = 30; continue; } + return ask(15, 5, 0, 0); + } + if (phase === 15) { + float(372, f(116)); + if (f(372) > width) { float(372, 0); elided(0, 0); continue; } + put(152, u(144)); put(172, u(144)); float(176, 0); phase = 16; + } + if (phase === 16) { if (u(152) >= u(148)) { phase = 23; continue; } return ask(17, 1, u(144), next(u(152))); } + if (phase === 17) { + if (f(116) > sub(width, f(372))) { phase = 23; continue; } + put(152, next(u(152))); put(172, u(152)); float(176, f(116)); phase = 16; continue; + } + if (phase === 23) { + let end = u(172); while (end > u(144) && isBreak(end - 1)) end--; + put(172, end); if (end === u(152)) { elided(end - u(144), f(176)); continue; } + return ask(22, 1, u(144), end); + } + if (phase === 22) { elided(u(172) - u(144), f(116)); continue; } + if (phase === 11) { float(372, f(116)); put(152, u(144)); put(172, u(144)); float(164, 0); float(176, 0); phase = 18; } + if (phase === 18) { + if (u(152) < u(148)) { + if (u(204) === 1) { takeElisionAdvance(f(advancesAt + u(152) * 4)); continue; } + return ask(19, 2, u(152), next(u(152))); + } + if (f(164) <= add(width, 0.125)) { float(372, 0); float(184, f(164)); put(188, 1); phase = 30; continue; } + if (f(372) > width) { float(372, 0); elided(0, 0); continue; } + float(184, f(176)); phase = 20; + } + if (phase === 19) { takeElisionAdvance(f(116)); continue; } + if (phase === 20) { + const p = u(172); if (p <= u(144) || !isBreak(p - 1)) { elided(p - u(144), max(0, f(184))); continue; } + if (u(204) === 1) { float(184, sub(f(184), f(advancesAt + (p - 1) * 4))); put(172, p - 1); continue; } + return ask(21, 2, p - 1, p); + } + if (phase === 21) { float(184, sub(f(184), f(116))); put(172, u(172) - 1); phase = 20; continue; } + if (phase === 24) { + float(372, f(116)); put(356, 1); put(364, 1); + if (f(372) > width) { put(360, 0); put(368, 0); float(372, 0); phase = 30; continue; } + let end = u(328), total = 0; const limit = u(328) + u(332); + while (end < limit) { const value = add(total, advance(end)); if (value > sub(width, f(372))) break; total = value; end++; } + while (end > u(328) && glyphBreak(end - 1)) end--; + const r = range(u(328), end - u(328)); put(360, Math.max(0, r.end - u(320))); put(368, end - u(328)); phase = 30; + } + if (phase === 0 && mode === 4) phase = 30; + if (phase === 30) { + const gs = u(328), gl = mode === 4 ? u(332) : u(364) !== 0 ? u(368) : u(332); + if (gl > 0 && gs < glyphCount) { rawGlyphBounds(gs, gl, f(352), f(348)); return finishBounds(); } + float(336, ox); float(340, sub(f(352), size)); + if (u(188) !== 0 && mode === 0) { float(344, f(184)); return finishBounds(); } + if (drawMeasure) return ask(31, 4, 0, textLength); + return ask(32, 1, u(320), Math.min(textLength, u(320) + (u(356) !== 0 ? u(360) : u(324)))); + } + if (phase === 31) { + if (u(120) === 0) return ask(32, 1, u(320), Math.min(textLength, u(320) + (u(356) !== 0 ? u(360) : u(324)))); + let total = 0; const end = Math.min(textLength, u(320) + (u(356) !== 0 ? u(360) : u(324))); + for (let n = u(320); n < end; n++) total = add(total, f(advancesAt + n * 4)); float(344, total); return finishBounds(); + } + if (phase === 32) { float(344, f(116)); return finishBounds(); } + if (phase === 0 && mode === 2) { + const r = lineRange(), offset = Math.max(r.start, Math.min(r.end, snap(u(56)))); + if (u(332) === 0 || u(328) >= glyphCount) return ask(40, 1, r.start, offset); + let x = f(336); + if (r.end > r.start && offset > r.start) { + if (offset >= r.end) x = add(f(336), f(344)); + else { + const raw = savedRawGlyphBounds(); + if (explicitLine()) { + x = add(f(336), f(344)); + for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) { + const gr = range(n, 1); if (gr.end <= r.start || gr.start >= r.end) continue; + const gx = glyphX(n, raw.first, raw.dx); + if (offset <= gr.start) { x = gx; break; } + if (offset < gr.end) { const count = scalarCount(gr.start, gr.end); let p = gr.start, index = 0; while (p < snap(offset)) { p = next(p); index++; } x = add(gx, mul(div(Math.fround(Math.min(index, count)), Math.fround(count)), max(1, advance(n)))); break; } + } + } else { + const count = scalarCount(r.start, r.end); let p = r.start, index = 0; while (p < offset) { p = next(p); index++; } + const relative = count === 0 ? 0 : Math.min(u(332), ratioIndex(index, u(332), count, false)); + x = relative === 0 ? f(336) : relative >= u(332) || u(328) + relative >= glyphCount ? add(f(336), f(344)) : glyphX(u(328) + relative, raw.first, raw.dx); + } + } + } + float(304, u(356) !== 0 || u(364) !== 0 ? min(x, add(f(336), f(344))) : x); return done(); + } + if (phase === 40) { const x = add(f(336), f(116)); float(304, u(356) !== 0 || u(364) !== 0 ? min(x, add(f(336), f(344))) : x); return done(); } + if (phase === 0 && mode === 3) { + const r = lineRange(), x = f(60), right = add(f(336), f(344)); + if (x <= f(336)) { put(308, r.start); return done(); } + if (u(356) !== 0 || u(364) !== 0) { + if (x >= right) { put(308, r.end); return done(); } + if (x >= sub(right, f(372))) { put(308, snap(Math.min(r.end, u(320) + (u(356) !== 0 ? u(360) : u(324))))); return done(); } + } + if (u(332) > 0 && u(328) < glyphCount) { + const raw = savedRawGlyphBounds(), explicit = explicitLine(); + for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) { + const gx = glyphX(n, raw.first, raw.dx), step = max(1, advance(n)), gr = range(n, 1); + if (explicit) { + if (gr.end <= r.start || gr.start >= r.end) continue; + if (x <= gx) { put(308, Math.max(r.start, gr.start)); return done(); } + if (x < add(gx, step)) { + const count = scalarCount(gr.start, gr.end), value = div(sub(x, gx), step), clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; + const index = Math.floor(add(mul(clamped, Math.fround(count)), 0.5)); put(308, scalarOffset(gr.start, gr.end, Math.min(index, count))); return done(); + } + } else if (x < add(gx, mul(step, 0.5))) { put(308, Math.max(r.start, Math.min(r.end, snap(gr.start)))); return done(); } + } + put(308, r.end); return done(); + } + put(152, r.start); float(164, f(336)); phase = 41; + } + if (phase === 41) { const r = lineRange(); if (u(152) >= r.end) { put(308, r.end); return done(); } return ask(42, drawMeasure ? 1 : 2, drawMeasure ? r.start : u(152), next(u(152))); } + if (phase === 42) { float(176, f(116)); if (drawMeasure) return ask(43, 1, lineRange().start, u(152)); phase = 44; } + if (phase === 43) { float(176, max(0, sub(f(176), f(116)))); phase = 44; } + if (phase === 44) { const step = max(1, f(176)); if (f(60) < add(f(164), mul(step, 0.5))) { put(308, u(152)); return done(); } float(164, add(f(164), step)); put(152, next(u(152))); phase = 41; continue; } + if (phase === 0 && (mode === 5 || mode === 8)) { + if (glyphCount === 0 && textLength === 0) return done(); + if (glyphCount === 0) return ask(50, 1, 0, textLength); + let minX = add(ox, glyph(0, 0)), maxX = add(minX, advance(0)), minY = sub(add(oy, glyph(0, 4)), size), maxY = add(add(oy, glyph(0, 4)), mul(size, 0.25)); + for (let n = 1; n < glyphCount; n++) { + const x = add(ox, glyph(n, 0)), y = add(oy, glyph(n, 4)); + minX = min(minX, x); maxX = max(maxX, add(x, advance(n))); minY = min(minY, sub(y, size)); maxY = max(maxY, add(y, mul(size, 0.25))); + } + float(336, minX); float(340, minY); float(344, max(mul(size, 0.25), sub(maxX, minX))); float(348, max(mul(size, 1.25), sub(maxY, minY))); + if (mode === 8) inflate(); put(300, 1); return done(); + } + if (phase === 50) { + float(336, ox); float(340, sub(oy, size)); float(344, max(mul(size, 0.25), sub(add(ox, f(116)), ox))); float(348, max(mul(size, 1.25), sub(add(oy, mul(size, 0.25)), sub(oy, size)))); + put(300, 1); + if (mode === 8) { inflate(); if (drawMeasure) return ask(62, 6, 0, textLength); } + return done(); + } + if (phase === 0 && mode === 6) { + put(300, 1); if (glyphCount > 0 || !drawMeasure) return done(); + float(220, f(336)); float(224, add(f(336), f(344))); + const start = Math.min(u(320), textLength), end = Math.min(textLength, start + (u(356) !== 0 ? u(360) : u(324))); + if (end > start) return ask(60, 6, start, end); + phase = 63; + } + if (phase === 60) { unionInk(f(220), f(352)); phase = 63; } + if (phase === 63) { if ((u(356) !== 0 || u(364) !== 0) && f(372) > 0) return ask(61, 7, 0, 0); return done(); } + if (phase === 61) { unionInk(sub(f(224), f(372)), f(352)); return done(); } + if (phase === 62) { unionInk(ox, oy); return done(); } + if (phase === 0 && mode === 7) { inflate(); put(300, 1); return done(); } + throw new Error("invalid text run phase"); + } +} diff --git a/packages/core/src/view_frontend.ts b/packages/core/src/view_frontend.ts index 3d875db9d..ae9fb8c85 100644 --- a/packages/core/src/view_frontend.ts +++ b/packages/core/src/view_frontend.ts @@ -937,6 +937,7 @@ const viewPrelude = "\n// Portable Native components compiled beside the committ readFileSync(new URL("./view_components.ts", import.meta.url), "utf8") + readFileSync(new URL("./runtime_policy.ts", import.meta.url), "utf8") + readFileSync(new URL("./paragraph_layout.ts", import.meta.url), "utf8") + + readFileSync(new URL("./text_run_layout.ts", import.meta.url), "utf8") + readFileSync(new URL("./control_appearance.ts", import.meta.url), "utf8") + readFileSync(new URL("./component_construction.ts", import.meta.url), "utf8") + readFileSync(new URL("./widget_motion.ts", import.meta.url), "utf8") + diff --git a/packages/core/test/text_run_layout.test.ts b/packages/core/test/text_run_layout.test.ts new file mode 100644 index 000000000..3a4bc879a --- /dev/null +++ b/packages/core/test/text_run_layout.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +const source = ["runtime_policy", "text_run_layout"].map(name => readFileSync(new URL(`../src/${name}.ts`, import.meta.url), "utf8")).join("\n"); +const { nscvTextRunLayout: plan } = await import(`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source) + "\nexport {nscvTextRunLayout};").toString("base64")}`); +function packet(text: string, width = 20, wrap = 1, mode = 0): Uint8Array { + const raw = new TextEncoder().encode(text), at = 512 + raw.length * 4; + const bytes = new Uint8Array(at + raw.length), w = new DataView(bytes.buffer); + bytes.set([54, 1, mode, 0, wrap, 0, 0, 0]); + for (const [p, value] of [[8, at], [12, bytes.length], [36, raw.length]]) w.setUint32(p!, value!, true); + w.setFloat32(16, 10, true); w.setFloat32(20, 3, true); w.setFloat32(24, 10, true); w.setFloat32(28, width, true); + bytes.set(raw, at); return bytes; +} +function execute(input: Uint8Array, batch = false): { result: DataView; calls: number[][]; bytes: Uint8Array } { + const calls: number[][] = []; + let bytes = input; + for (let i = 0; i < 10000; i++) { + const before = bytes.slice(), result = plan(bytes); assert.deepEqual(bytes, before); + assert.equal(result.length, 512); + const w = new DataView(result.buffer); + if (result[3] === 2) return { result: w, calls, bytes: result }; + assert.equal(result[3], 1); + const kind = w.getUint32(100, true), start = w.getUint32(104, true), end = w.getUint32(108, true); + calls.push([kind, start, end]); + const continuation = bytes.slice(); continuation.set(result); + const state = new DataView(continuation.buffer); + if (kind === 3 || kind === 4) { + state.setUint32(120, batch ? 1 : 0, true); + if (batch) for (let p = 0; p < state.getUint32(36, true); p++) state.setFloat32(512 + p * 4, 5, true); + } else state.setFloat32(116, kind === 5 ? 5 : (end - start) * 5, true); + state.setUint32(112, 1, true); bytes = continuation; + } + throw new Error("text continuation did not finish"); +} +test("ordinary text retains hard-line whitespace and exact soft-wrap cursors", () => { + const { result: r } = execute(packet("abc de")); + assert.equal(r.getUint32(320, true), 0); assert.equal(r.getUint32(324, true), 3); + assert.equal(r.getUint32(288, true), 4); assert.equal(r.getUint32(292, true), 1); assert.equal(r.getUint32(296, true), 0); + assert.equal(r.getFloat32(344, true), 15); + const next = packet("a\n b", 50); new DataView(next.buffer).setUint32(44, 2, true); + const last = execute(next).result; + assert.equal(last.getUint32(320, true), 2); assert.equal(last.getUint32(324, true), 3); + assert.equal(last.getUint32(288, true), 2); assert.equal(last.getUint32(296, true), 1); +}); +test("elision preserves hidden logical ranges and trims only soft break bytes", () => { + const b = packet("ab cd", 20, 0); b[5] = 1; + const { result: r } = execute(b); + assert.equal(r.getUint32(324, true), 5); assert.equal(r.getUint32(356, true), 1); assert.equal(r.getUint32(360, true), 2); + assert.equal(r.getFloat32(372, true), 5); assert.equal(r.getFloat32(344, true), 15); assert.equal(r.getFloat32(336, true), 5.5); + const cr = execute(packet("ab\rcd", 20, 0)).result; + assert.equal(cr.getUint32(360, true), 3); + const tiny = execute(packet("abc", 2, 0)).result; + assert.equal(tiny.getUint32(360, true), 0); assert.equal(tiny.getFloat32(372, true), 0); assert.equal(tiny.getFloat32(344, true), 0); +}); +test("batched line decisions use owned copied advances", () => { + const b = packet("abc de"); b[7] = 3; + const { result: r, calls } = execute(b, true); + assert.equal(r.getUint32(324, true), 3); + assert.deepEqual(calls, [[3, 0, 6], [4, 0, 6]]); + assert.equal(r.getFloat32(344, true), 15); +}); +test("standalone line ends and empty runs retain iterator state", () => { + const r = execute(packet("abc de", 20, 1, 1)).result; + assert.equal(r.getUint32(308, true), 3); + const empty = execute(packet("")).result; + assert.equal(empty.getUint32(300, true), 1); assert.equal(empty.getUint32(296, true), 1); assert.equal(empty.getUint32(288, true), 0); +}); +test("long text continuations retain caller facts and return fixed owned state", () => { + const b = packet("abcdef ".repeat(400), 25); b[7] = 3; + const before = b.slice(), { result: r, bytes } = execute(b, true); + assert.deepEqual(b, before); assert.equal(bytes.length, 512); + assert.equal(r.getUint32(324, true), 5); + const saved = bytes.slice(); b.fill(0); + assert.deepEqual(bytes, saved); +}); +test("text packets reject malformed headers, mutable initial state and missing replies", () => { + assert.throws(() => plan(new Uint8Array(511)), /invalid/); + for (const [at, value] of [[0, 53], [1, 2], [2, 9], [3, 2], [4, 3], [5, 3], [6, 2], [7, 4], [8, 0], [12, 0], [52, 2], [80, 1], [96, 1], [356, 2], [376, 1]]) { + const b = packet("abc"); b[at!] = value!; assert.throws(() => plan(b), /invalid/, String(at)); + } + const q = packet("abc"); q.set(plan(q)); assert.throws(() => plan(q), /reply/); + for (const [at, value] of [[96, 40], [100, 0], [100, 2], [104, 99], [108, 99], [120, 2]]) { + const b = q.slice(), w = new DataView(b.buffer); w.setUint32(112, 1, true); w.setUint32(at!, value!, true); + assert.throws(() => plan(b), /invalid/, String(at)); + } +}); diff --git a/packages/core/test/view_frontend.test.ts b/packages/core/test/view_frontend.test.ts index c46db8d79..d92a3e965 100644 --- a/packages/core/test/view_frontend.test.ts +++ b/packages/core/test/view_frontend.test.ts @@ -269,7 +269,7 @@ test("the view frontend and compiled portable component bundle typecheck", () => }; // Production joins these policies in one module before compiling the view. const bundlePath = new URL("../src/view_components_typecheck.ts", import.meta.url).pathname; - const bundle = ["view_components.ts", "runtime_policy.ts", "paragraph_layout.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "widget_paint.ts", "widget_presentation.ts", "widget_paint_walk.ts", "control_geometry.ts", "control_content.ts", "surface_recipes.ts", "widget_metrics.ts", "intrinsic_measure.ts", "measurement_coordination.ts", "flow_measurement.ts", "layout_coordination.ts", "render_coordination.ts", "control_commands.ts", "control_payloads.ts", "control_primitives.ts", "leaf_plans.ts", "chart_plans.ts", "indicator_plans.ts", "effect_plans.ts", "stream_policy.ts"] + const bundle = ["view_components.ts", "runtime_policy.ts", "text_run_layout.ts", "paragraph_layout.ts", "control_appearance.ts", "component_construction.ts", "widget_motion.ts", "component_composition.ts", "code_content.ts", "chart_content.ts", "markdown_content.ts", "widget_audits.ts", "widget_routing.ts", "widget_changes.ts", "widget_paint.ts", "widget_presentation.ts", "widget_paint_walk.ts", "control_geometry.ts", "control_content.ts", "surface_recipes.ts", "widget_metrics.ts", "intrinsic_measure.ts", "measurement_coordination.ts", "flow_measurement.ts", "layout_coordination.ts", "render_coordination.ts", "control_commands.ts", "control_payloads.ts", "control_primitives.ts", "leaf_plans.ts", "chart_plans.ts", "indicator_plans.ts", "effect_plans.ts", "stream_policy.ts"] .map(name => readFileSync(new URL(`../src/${name}`, import.meta.url), "utf8")).join("\n"); const host = ts.createCompilerHost(options); const readSource = host.getSourceFile.bind(host); diff --git a/src/primitives/canvas/commands.zig b/src/primitives/canvas/commands.zig index 7d073a0b3..39fe856f5 100644 --- a/src/primitives/canvas/commands.zig +++ b/src/primitives/canvas/commands.zig @@ -386,6 +386,8 @@ pub const max_display_list_text_bytes: usize = 32768; pub const Builder = struct { commands: []CanvasCommand, len: usize = 0, + /// Portable planner stamped onto emitted text as process-local context. + text_run_policy: ?*const fn ([]const u8, []u8) usize = null, /// Builder-owned storage for path elements built at emit time (the /// checkbox mark, chart polylines and bands, the spinner arc and /// segments). Emitters allocate from here via `allocPathElements`, @@ -521,7 +523,9 @@ pub const Builder = struct { } pub fn drawText(self: *Builder, value: DrawText) error{DisplayListFull}!void { - try self.append(.{ .draw_text = value }); + var text = value; + if (text.text_run_policy == null) text.text_run_policy = self.text_run_policy; + try self.append(.{ .draw_text = text }); } pub fn shadow(self: *Builder, value: Shadow) error{DisplayListFull}!void { diff --git a/src/primitives/canvas/layout_audit.zig b/src/primitives/canvas/layout_audit.zig index d044ce341..fa723437a 100644 --- a/src/primitives/canvas/layout_audit.zig +++ b/src/primitives/canvas/layout_audit.zig @@ -288,6 +288,7 @@ fn auditPlainTextOverflow(widget: Widget, frame: geometry.RectF, node_index: usi .line_height = line_height, .wrap = .none, .alignment = widget.text_alignment, + .text_run_policy = tokens.text_run_policy, .measure = tokens.text_measure, }); line_count += 1; diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index d6945c79a..fa651b18f 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -173,6 +173,9 @@ pub const text_ellipsis = text_model.text_ellipsis; pub const textEllipsisAdvance = text_model.textEllipsisAdvance; pub const textLayoutKeysEqual = text_model.textLayoutKeysEqual; pub const TextLayoutOptions = text_model.TextLayoutOptions; +pub const TextLineIterator = text_model.TextLineIterator; +pub const textLineCaretX = text_model.textLineCaretX; +pub const text_run_policy = @import("text_run_policy.zig"); pub const TextMeasureProvider = text_model.TextMeasureProvider; pub const TextInkMetrics = text_model.TextInkMetrics; /// Batched-measurement invalidation seam (see text_measure_cache.zig): diff --git a/src/primitives/canvas/text_layout.zig b/src/primitives/canvas/text_layout.zig index 20d90919f..4dd267a02 100644 --- a/src/primitives/canvas/text_layout.zig +++ b/src/primitives/canvas/text_layout.zig @@ -7,6 +7,7 @@ const text_layout_cache = @import("text_layout_cache.zig"); const text_layout_hash = @import("text_layout_hash.zig"); const text_measure_cache = @import("text_measure_cache.zig"); const text_metrics = @import("text_metrics.zig"); +const compiled_run = @import("text_run_policy.zig"); const Error = canvas.Error; const FontId = canvas.FontId; @@ -359,6 +360,13 @@ pub const TextLineIterator = struct { pub fn next(self: *TextLineIterator) ?TextLine { if (self.finished) return null; + if (self.options.text_run_policy orelse self.text.text_run_policy) |policy| { + const result = compiled_run.execute(policy, 0, self.text, self.options, self.cursor, self.index, self.finished, .{}, 0, 0); + self.cursor = result.cursor; + self.index = result.index; + self.finished = result.finished; + return result.line; + } if (self.text.glyphs.len > 0) return self.nextGlyphLine(); return self.nextPlainLine(); } @@ -430,6 +438,10 @@ fn textInkInsets(size: f32) geometry.InsetsF { } pub fn textBounds(value: DrawText) ?geometry.RectF { + const run_options = textLayoutOptionsForDrawText(.{}, value); + if (value.text_layout == null) if (run_options.text_run_policy) |policy| { + return if (compiled_run.execute(policy, 8, value, run_options, 0, 0, false, .{}, 0, 0).line) |line| line.bounds else null; + }; if (value.text_layout) |options| { var lines: [max_text_bounds_layout_lines]TextLine = undefined; if (layoutTextRun(value, options, &lines)) |layout| { @@ -446,7 +458,7 @@ pub fn textBounds(value: DrawText) ?geometry.RectF { while (iterator.next()) |line| { bounds = unionOptionalBounds(bounds, textLineInkBounds(value, line)); } - if (bounds) |value_bounds| return value_bounds.inflate(textInkInsets(value.size)); + if (bounds) |value_bounds| return inflateTextBounds(value, value_bounds); } const metric = metricTextBounds(value) orelse return null; @@ -471,10 +483,17 @@ fn textBoundsForLines(value: DrawText, lines: []const TextLine) ?geometry.RectF for (lines) |line| { bounds = unionOptionalBounds(bounds, textLineInkBounds(value, line)); } - return if (bounds) |value_bounds| value_bounds.inflate(textInkInsets(value.size)) else null; + return if (bounds) |value_bounds| inflateTextBounds(value, value_bounds) else null; +} +fn inflateTextBounds(text: DrawText, bounds: geometry.RectF) geometry.RectF { + const options = textLayoutOptionsForDrawText(.{}, text); + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 7, text, options, 0, 0, false, .{ .bounds = bounds }, 0, 0).line.?.bounds; + return bounds.inflate(textInkInsets(text.size)); } fn textLineInkBounds(value: DrawText, line: TextLine) geometry.RectF { + const options = textLayoutOptionsForDrawText(.{}, value); + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 6, value, options, 0, 0, false, line, 0, 0).line.?.bounds; var bounds = line.bounds; if (value.glyphs.len > 0) return bounds; if (drawTextMeasure(value)) |provider| { @@ -505,6 +524,8 @@ fn textLineInkBounds(value: DrawText, line: TextLine) geometry.RectF { } fn metricTextBounds(value: DrawText) ?geometry.RectF { + const options = textLayoutOptionsForDrawText(.{}, value); + if (options.text_run_policy) |policy| return if (compiled_run.execute(policy, 5, value, options, 0, 0, false, .{}, 0, 0).line) |line| line.bounds else null; if (value.glyphs.len == 0 and value.text.len == 0) return null; var min_x = value.origin.x; @@ -738,6 +759,7 @@ fn streamTextLineForOffset( } pub fn nextTextLineEnd(text: []const u8, start: usize, font_id: FontId, size: f32, options: TextLayoutOptions) usize { + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 1, .{ .font_id = font_id, .size = size, .origin = .{}, .color = .{}, .text = text, .measure = options.measure }, options, start, 0, false, .{}, 0, 0).offset; const max_width = if (options.max_width > 0) options.max_width else std.math.inf(f32); if (options.wrap == .none or max_width == std.math.inf(f32)) { return nextExplicitLineEnd(text, start); @@ -936,6 +958,8 @@ fn textLineRangeForLength(text_len: usize, line: TextLine) TextRange { } pub fn textLineCaretX(text: DrawText, line: TextLine, offset: usize) f32 { + const options = textLayoutOptionsForDrawText(.{}, text); + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 2, text, options, 0, 0, false, line, offset, 0).scalar; const range = textLineRange(text, line); const snapped = clampTextOffsetToRange(text.text, range, offset); const x = if (line.glyph_len > 0 and line.glyph_start < text.glyphs.len) @@ -972,6 +996,8 @@ fn textLineGlyphCaretX(text: DrawText, line: TextLine, range: TextRange, offset: } fn textLineOffsetForX(text: DrawText, line: TextLine, x: f32) usize { + const options = textLayoutOptionsForDrawText(.{}, text); + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 3, text, options, 0, 0, false, line, 0, x).offset; const range = textLineRange(text, line); if (x <= line.bounds.x) return range.start; // Elided lines: the hidden tail has no painted geometry, so a point @@ -1042,6 +1068,8 @@ fn lineHeight(text: DrawText, options: TextLayoutOptions) f32 { } pub fn textLineBounds(text: DrawText, text_start: usize, text_len: usize, glyph_start: usize, glyph_len: usize, baseline: f32, line_height_value: f32) geometry.RectF { + const options = textLayoutOptionsForDrawText(.{}, text); + if (options.text_run_policy) |policy| return compiled_run.execute(policy, 4, text, options, 0, 0, false, .{ .text_start = text_start, .text_len = text_len, .glyph_start = glyph_start, .glyph_len = glyph_len, .baseline = baseline, .bounds = .{ .height = line_height_value } }, 0, 0).line.?.bounds; if (glyph_len > 0 and glyph_start < text.glyphs.len) { const glyphs = text.glyphs[glyph_start..@min(text.glyphs.len, glyph_start + glyph_len)]; const origin_x = glyphs[0].x; diff --git a/src/primitives/canvas/text_layout_hash.zig b/src/primitives/canvas/text_layout_hash.zig index 0fc887b45..e0e24aac5 100644 --- a/src/primitives/canvas/text_layout_hash.zig +++ b/src/primitives/canvas/text_layout_hash.zig @@ -23,6 +23,7 @@ const resourceHashPoint = hash_model.resourceHashPoint; pub fn textLayoutOptionsForDrawText(frame_options: TextLayoutOptions, text: DrawText) TextLayoutOptions { var options = text.text_layout orelse frame_options; if (options.measure == null) options.measure = text.measure; + if (options.text_run_policy == null) options.text_run_policy = text.text_run_policy; return options; } diff --git a/src/primitives/canvas/text_layout_types.zig b/src/primitives/canvas/text_layout_types.zig index cf2571295..93dbe492f 100644 --- a/src/primitives/canvas/text_layout_types.zig +++ b/src/primitives/canvas/text_layout_types.zig @@ -20,6 +20,8 @@ pub const DrawText = struct { /// Process-local measurement context used for host-aware ink bounds. /// Excluded from equality, hashing, and serialization. measure: ?*const text_metrics.TextMeasureProvider = null, + /// Process-local portable planner, excluded from content identity. + text_run_policy: ?*const fn ([]const u8, []u8) usize = null, text_layout: ?TextLayoutOptions = null, }; @@ -63,6 +65,7 @@ pub const TextLayoutOptions = struct { /// Deliberately excluded from equality, hashing, and serialization: /// it is process-local layout context, not drawn content. measure: ?*const text_metrics.TextMeasureProvider = null, + text_run_policy: ?*const fn ([]const u8, []u8) usize = null, }; pub const TextLine = struct { diff --git a/src/primitives/canvas/text_run_policy.zig b/src/primitives/canvas/text_run_policy.zig new file mode 100644 index 000000000..4bf1631ad --- /dev/null +++ b/src/primitives/canvas/text_run_policy.zig @@ -0,0 +1,230 @@ +//! Copied transport for portable ordinary text planning. The host executes +//! requested font capabilities and copies results into caller-owned state. +const std = @import("std"); +const types = @import("text_layout_types.zig"); +const metrics = @import("text_metrics.zig"); +const cache = @import("text_measure_cache.zig"); +pub const Policy = *const fn ([]const u8, []u8) usize; +pub const Result = struct { + line: ?types.TextLine, + cursor: usize, + index: usize, + finished: bool, + scalar: f32, + offset: usize, +}; +fn capabilityKind(mode: u8, phase: u32, draw_measure: bool) ?u32 { + return switch (mode) { + 0 => switch (phase) { + 1, 12 => 3, + 3, 14, 17, 22, 32 => 1, + 11, 15, 24 => 5, + 19, 21 => 2, + 31 => 4, + else => null, + }, + 1 => switch (phase) { + 1 => 3, + 3 => 1, + else => null, + }, + 2 => if (phase == 40) 1 else null, + 3 => switch (phase) { + 42 => if (draw_measure) 1 else 2, + 43 => 1, + else => null, + }, + 4 => switch (phase) { + 31 => 4, + 32 => 1, + else => null, + }, + 5 => if (phase == 50) 1 else null, + 6 => switch (phase) { + 60 => 6, + 61 => 7, + else => null, + }, + 8 => switch (phase) { + 50 => 1, + 62 => 6, + else => null, + }, + else => null, + }; +} +fn word(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} +fn float(bytes: []const u8, at: usize) f32 { + return @bitCast(word(bytes, at)); +} +fn put(bytes: []u8, at: usize, value: usize) void { + std.mem.writeInt(u32, bytes[at..][0..4], std.math.cast(u32, value) orelse @panic("text run integer range"), .little); +} +fn setFloat(bytes: []u8, at: usize, value: f32) void { + std.mem.writeInt(u32, bytes[at..][0..4], @bitCast(value), .little); +} +fn writeLine(bytes: []u8, line: types.TextLine) void { + put(bytes, 320, line.text_start); + put(bytes, 324, line.text_len); + put(bytes, 328, line.glyph_start); + put(bytes, 332, line.glyph_len); + setFloat(bytes, 336, line.bounds.x); + setFloat(bytes, 340, line.bounds.y); + setFloat(bytes, 344, line.bounds.width); + setFloat(bytes, 348, line.bounds.height); + setFloat(bytes, 352, line.baseline); + put(bytes, 356, @intFromBool(line.elided_text_len != null)); + put(bytes, 360, line.elided_text_len orelse 0); + put(bytes, 364, @intFromBool(line.elided_glyph_len != null)); + put(bytes, 368, line.elided_glyph_len orelse 0); + setFloat(bytes, 372, line.ellipsis_advance); +} +fn readLine(bytes: []const u8) types.TextLine { + return .{ + .text_start = word(bytes, 320), + .text_len = word(bytes, 324), + .glyph_start = word(bytes, 328), + .glyph_len = word(bytes, 332), + .bounds = .{ .x = float(bytes, 336), .y = float(bytes, 340), .width = float(bytes, 344), .height = float(bytes, 348) }, + .baseline = float(bytes, 352), + .elided_text_len = if (word(bytes, 356) != 0) word(bytes, 360) else null, + .elided_glyph_len = if (word(bytes, 364) != 0) word(bytes, 368) else null, + .ellipsis_advance = float(bytes, 372), + }; +} +/// Reject altered source, invalid ranges, malformed result flags and +/// unrecognized capability continuations before any host callback runs. +pub fn resultValid(request: []const u8, result: []const u8) bool { + if (request.len < 512 or result.len != 512 or result[3] < 1 or result[3] > 2 or + !std.mem.eql(u8, request[0..3], result[0..3]) or !std.mem.eql(u8, request[4..80], result[4..80])) return false; + const text_len = word(request, 36); + const glyph_count = word(request, 40); + const advance_at: u64 = 512 + @as(u64, glyph_count) * 32; + const text_at: u64 = advance_at + @as(u64, text_len) * 4; + if (text_at > request.len or word(request, 8) != text_at or word(request, 12) != request.len or text_at + text_len != request.len) return false; + for (result[376..512]) |byte| if (byte != 0) return false; + if (word(result, 296) > 1 or word(result, 300) > 1 or word(result, 356) > 1 or word(result, 364) > 1) return false; + if (request[2] == 2 or request[2] == 3) if (!std.mem.eql(u8, request[320..384], result[320..384])) return false; + if (result[3] == 2) { + if (word(result, 96) != 0 or word(result, 100) != 0 or word(result, 112) != 0) return false; + if (request[2] == 0 and word(result, 300) != 0) { + if (@as(u64, word(result, 320)) + word(result, 324) > text_len or + (glyph_count > 0 and @as(u64, word(result, 328)) + word(result, 332) > glyph_count) or + word(result, 292) != @as(u64, word(request, 48)) + 1 or + word(result, 288) > (if (glyph_count > 0) glyph_count else text_len) or + (word(result, 356) != 0 and word(result, 360) > word(result, 324)) or + (word(result, 364) != 0 and word(result, 368) > word(result, 332))) return false; + } + if ((request[2] == 1 or request[2] == 3) and word(result, 308) > text_len) return false; + return true; + } + if (word(result, 112) != 0 or word(result, 104) > word(result, 108) or word(result, 108) > text_len) return false; + const expected = capabilityKind(request[2], word(result, 96), request[7] & 2 != 0) orelse return false; + if (word(result, 100) != expected) return false; + if ((expected == 3 or expected == 4) and (word(result, 104) != 0 or word(result, 108) != text_len)) return false; + if ((expected == 5 or expected == 7) and (word(result, 104) != 0 or word(result, 108) != 0)) return false; + if (request[3] == 1 and std.mem.eql(u8, request[96..112], result[96..112]) and std.mem.eql(u8, request[144..512], result[144..512])) return false; + return true; +} +pub fn execute( + policy: Policy, + mode: u8, + text: types.DrawText, + options: types.TextLayoutOptions, + cursor: usize, + index: usize, + finished: bool, + line: types.TextLine, + offset: usize, + x: f32, +) Result { + const allocator = std.heap.page_allocator; + const glyph_bytes = std.math.mul(usize, text.glyphs.len, 32) catch @panic("text run glyph capacity"); + const advance_at = std.math.add(usize, 512, glyph_bytes) catch @panic("text run packet capacity"); + const advance_bytes = std.math.mul(usize, text.text.len, 4) catch @panic("text run advance capacity"); + const text_at = std.math.add(usize, advance_at, advance_bytes) catch @panic("text run packet capacity"); + const total = std.math.add(usize, text_at, text.text.len) catch @panic("text run text capacity"); + if (total > std.math.maxInt(u32)) @panic("text run packet range"); + var small_request: [4096]u8 = undefined; + const request = if (total <= small_request.len) small_request[0..total] else allocator.alloc(u8, total) catch @panic("text run request allocation"); + defer if (total > small_request.len) allocator.free(request); + var result: [512]u8 = undefined; + const draw_measure = if (text.text_layout) |o| o.measure orelse text.measure else text.measure; + @memset(request, 0); + request[0..8].* = .{ 54, 1, mode, 0, @intFromEnum(options.wrap), @intFromEnum(options.alignment), @intFromEnum(options.overflow), @as(u8, @intFromBool(options.measure != null)) | (@as(u8, @intFromBool(draw_measure != null)) << 1) }; + put(request, 8, text_at); + put(request, 12, total); + setFloat(request, 16, text.size); + setFloat(request, 20, text.origin.x); + setFloat(request, 24, text.origin.y); + setFloat(request, 28, options.max_width); + setFloat(request, 32, options.line_height); + put(request, 36, text.text.len); + put(request, 40, text.glyphs.len); + put(request, 44, cursor); + put(request, 48, index); + put(request, 52, @intFromBool(finished)); + put(request, 56, @min(offset, std.math.maxInt(u32))); + setFloat(request, 60, x); + std.mem.writeInt(u64, request[64..72], text.font_id, .little); + writeLine(request, line); + for (text.glyphs, 0..) |glyph, n| { + const at = 512 + n * 32; + setFloat(request, at, glyph.x); + setFloat(request, at + 4, glyph.y); + setFloat(request, at + 8, glyph.advance); + put(request, at + 12, glyph.text_start); + put(request, at + 16, glyph.text_len); + } + @memcpy(request[text_at..], text.text); + const quota = std.math.mul(usize, total, 8) catch @panic("text run continuation range"); + var calls: usize = 0; + while (true) { + calls += 1; + if (calls > quota) @panic("text run continuation did not finish"); + @memset(&result, 0xa5); + if (policy(request, &result) != result.len or !resultValid(request, &result)) @panic("invalid text run continuation"); + if (result[3] == 2) break; + const kind = word(&result, 100); + const start = word(&result, 104); + const end = word(&result, 108); + const phase = word(&result, 96); + @memcpy(request[0..512], &result); + switch (kind) { + 1 => { + const measure = if (phase == 32 or phase == 40 or phase == 42 or phase == 43 or phase == 50) draw_measure else options.measure; + setFloat(request, 116, metrics.measureTextWidthForFont(measure, text.font_id, text.text[start..end], text.size)); + }, + 2 => setFloat(request, 116, metrics.estimateTextAdvanceForBytes(text.font_id, text.text[start..end], text.size)), + 3, 4 => { + const provider = if (kind == 3) options.measure else draw_measure; + const advances = if (provider) |p| if (kind == 3) cache.textRunAdvances(p, text.font_id, text.size, text.text) else cache.cachedTextRunAdvances(p, text.font_id, text.size, text.text) else null; + put(request, 120, @intFromBool(advances != null)); + if (advances) |values| for (values, 0..) |value, n| setFloat(request, advance_at + n * 4, value); + }, + 5 => setFloat(request, 116, if (options.measure) |p| p.measureWidth(text.font_id, text.size, "\u{2026}") else metrics.estimatedTextEllipsisAdvance(text.font_id, text.size)), + 6, 7 => { + const ink = if (draw_measure) |p| p.measureInk(text.font_id, text.size, if (kind == 7) "\u{2026}" else text.text[start..end]) else null; + put(request, 120, @intFromBool(ink != null)); + if (ink) |value| { + setFloat(request, 124, value.min_x); + setFloat(request, 128, value.min_y); + setFloat(request, 132, value.max_x); + setFloat(request, 136, value.max_y); + } + }, + else => unreachable, + } + put(request, 112, 1); + } + return .{ + .line = if (word(&result, 300) != 0 or mode == 4) readLine(&result) else null, + .cursor = word(&result, 288), + .index = word(&result, 292), + .finished = word(&result, 296) != 0, + .scalar = float(&result, 304), + .offset = word(&result, 308), + }; +} diff --git a/src/primitives/canvas/tokens.zig b/src/primitives/canvas/tokens.zig index 3102b7171..ff446795f 100644 --- a/src/primitives/canvas/tokens.zig +++ b/src/primitives/canvas/tokens.zig @@ -1935,6 +1935,7 @@ pub const DesignTokens = struct { control_geometry_policy: ?*const fn ([]const u8, []u8) usize = null, code_content_policy: ?*const fn ([]const u8, []u8) usize = null, paragraph_policy: ?*const fn ([]const u8, []u8) usize = null, + text_run_policy: ?*const fn ([]const u8, []u8) usize = null, pub fn theme(options: ThemeOptions) DesignTokens { // The pack resolves the register (palette, control tables, and diff --git a/src/primitives/canvas/widget_audit_policy.zig b/src/primitives/canvas/widget_audit_policy.zig index 55e9f5764..c654bd969 100644 --- a/src/primitives/canvas/widget_audit_policy.zig +++ b/src/primitives/canvas/widget_audit_policy.zig @@ -101,6 +101,7 @@ fn measure(widget: widgets.Widget, raw_frame: geometry.RectF, tokens: tokens_mod .line_height = line_height, .wrap = .none, .alignment = widget.text_alignment, + .text_run_policy = tokens.text_run_policy, .measure = tokens.text_measure, }); measured.lines += 1; diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index c5255839d..249992fcf 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -174,6 +174,9 @@ fn activePaintWalk(layout: anytype) ?paint_walk_policy.Plan { threadlocal var tree_visible_bounds: ?geometry.RectF = null; pub fn emitWidgetTree(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { + const saved_policy = builder.text_run_policy; + builder.text_run_policy = tokens.text_run_policy; + defer builder.text_run_policy = saved_policy; scrim_viewport = widget.frame.normalized(); tree_visible_bounds = widget.frame.normalized(); defer tree_visible_bounds = null; @@ -185,6 +188,9 @@ pub fn emitWidgetLayout(builder: *Builder, layout: anytype, tokens: DesignTokens } pub fn emitWidgetLayoutWithState(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void { + const saved_policy = builder.text_run_policy; + builder.text_run_policy = tokens.text_run_policy; + defer builder.text_run_policy = saved_policy; const saved_copy = painting_drag_copy; painting_drag_copy = false; defer painting_drag_copy = saved_copy; diff --git a/src/primitives/canvas/widget_text_input.zig b/src/primitives/canvas/widget_text_input.zig index 0bba5a5ad..55d6c3892 100644 --- a/src/primitives/canvas/widget_text_input.zig +++ b/src/primitives/canvas/widget_text_input.zig @@ -183,7 +183,7 @@ pub fn widgetTextInputLayoutOptions(widget: Widget, tokens: DesignTokens, text_s if (widget.text_no_wrap) request.flags(8); request.bytes[8] = if (widget.kind == .textarea) 2 else if (widget.kind == .text_field) 1 else 0; const result = request.run(tokens); - return .{ .max_width = result.scalar, .line_height = result.auxiliary, .wrap = result.wrap, .overflow = .clip, .measure = tokens.text_measure }; + return .{ .max_width = result.scalar, .line_height = result.auxiliary, .wrap = result.wrap, .overflow = .clip, .text_run_policy = tokens.text_run_policy, .measure = tokens.text_measure }; } return .{ .max_width = @max(1, widget.frame.width - inset - trailing_inset), @@ -194,6 +194,7 @@ pub fn widgetTextInputLayoutOptions(widget: Widget, tokens: DesignTokens, text_s // so the honest treatment is the frame clip, not an ellipsis // hiding the caret's own neighborhood. .overflow = .clip, + .text_run_policy = tokens.text_run_policy, .measure = tokens.text_measure, }; } diff --git a/src/primitives/canvas/widget_text_select.zig b/src/primitives/canvas/widget_text_select.zig index 4e353df78..1270d7fe9 100644 --- a/src/primitives/canvas/widget_text_select.zig +++ b/src/primitives/canvas/widget_text_select.zig @@ -122,6 +122,7 @@ fn staticTextLayoutOptions(widget: Widget, tokens: DesignTokens, text_size: f32) .wrap = .none, .alignment = widget.text_alignment, .overflow = widget.text_overflow, + .text_run_policy = tokens.text_run_policy, .measure = tokens.text_measure, }; } diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index 86328c943..fb9d997aa 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -452,6 +452,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF stamped.code_content_policy = core.nativeWindowPolicy; if (options.paragraph_policy != null) @panic("TsUiApp owns paragraph_policy - remove custom paragraph wiring"); stamped.paragraph_policy = core.nativeWindowPolicy; + if (options.text_run_policy != null) @panic("TsUiApp owns text_run_policy - remove custom text run wiring"); + stamped.text_run_policy = core.nativeWindowPolicy; if (options.chart_content_policy != null) @panic("TsUiApp owns chart_content_policy - remove custom chart content wiring"); stamped.chart_content_policy = core.nativeWindowPolicy; if (options.markdown_content_policy != null) @panic("TsUiApp owns markdown_content_policy - remove custom Markdown content wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 27e6bbca9..a6cbca053 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -501,6 +501,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe composition_policy: ?canvas.ComponentCompositionPolicy.Policy = null, code_content_policy: ?canvas.CodeContentPolicy.Policy = null, paragraph_policy: ?*const fn ([]const u8, []u8) usize = null, + text_run_policy: ?*const fn ([]const u8, []u8) usize = null, chart_content_policy: ?canvas.ChartContentPolicy.Policy = null, markdown_content_policy: ?canvas.MarkdownContentPolicy.Policy = null, construction_policy: ?canvas.ComponentConstructionPolicy.Policy = null, @@ -2046,6 +2047,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.control_geometry_policy = self.options.control_geometry_policy; tokens.code_content_policy = self.options.code_content_policy; tokens.paragraph_policy = self.options.paragraph_policy; + tokens.text_run_policy = self.options.text_run_policy; return tokens; } const state = self.currentThemeState(); @@ -2110,6 +2112,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe tokens.control_geometry_policy = self.options.control_geometry_policy; tokens.code_content_policy = self.options.code_content_policy; tokens.paragraph_policy = self.options.paragraph_policy; + tokens.text_run_policy = self.options.text_run_policy; return tokens; } diff --git a/tests/ts-core/text_run_layout_e2e_tests.zig b/tests/ts-core/text_run_layout_e2e_tests.zig new file mode 100644 index 000000000..6530c5f92 --- /dev/null +++ b/tests/ts-core/text_run_layout_e2e_tests.zig @@ -0,0 +1,246 @@ +//! Complete ordinary line, elision, caret and hit geometry comparisons. +const std = @import("std"); +const sdk = @import("native_sdk"); +const c = sdk.canvas; +const core = @import("ts_persist_core"); +const exact = @import("component_construction_e2e_tests.zig").exact; +const policy = c.text_run_policy; +fn compare(text: c.DrawText, options: c.TextLayoutOptions, capacity: usize) !void { + errdefer std.debug.print("text run case bytes={any} glyphs={d} size={d} width={d} wrap={s} align={s} overflow={s} capacity={d}\n", .{ text.text, text.glyphs.len, text.size, options.max_width, @tagName(options.wrap), @tagName(options.alignment), @tagName(options.overflow), capacity }); + var expected_lines: [400]c.TextLine = undefined; + var actual_lines: [400]c.TextLine = undefined; + var compiled_text = text; + compiled_text.text_run_policy = core.nativeWindowPolicy; + var compiled_options = options; + compiled_options.text_run_policy = core.nativeWindowPolicy; + if (compiled_text.text_layout) |*value| value.text_run_policy = core.nativeWindowPolicy; + const expected = c.layoutTextRun(text, options, expected_lines[0..capacity]); + const actual = c.layoutTextRun(compiled_text, compiled_options, actual_lines[0..capacity]); + if (expected) |layout| { + try exact(layout, try actual); + core.rt.frameReset(); + try exact(layout, try actual); + } else |err| try std.testing.expectError(err, actual); + var a = c.TextLineIterator.init(text, options); + var b = c.TextLineIterator.init(compiled_text, compiled_options); + while (true) { + const line = a.next(); + try exact(line, b.next()); + try std.testing.expectEqual(a.cursor, b.cursor); + try std.testing.expectEqual(a.index, b.index); + try std.testing.expectEqual(a.finished, b.finished); + if (line == null) break; + } + // Streaming queries retain their original lack of a line-count cap. + for ([_]usize{ 0, 1, text.text.len / 2, text.text.len, text.text.len + 3 }) |offset| { + for (std.enums.values(c.TextCaretAffinity)) |affinity| try exact(c.layoutTextCaretRectWithAffinity(text, options, offset, affinity), c.layoutTextCaretRectWithAffinity(compiled_text, compiled_options, offset, affinity)); + } + for ([_]sdk.geometry.PointF{ .{}, .{ .x = 5, .y = 20 }, .{ .x = 60, .y = 31 }, .{ .x = 1000, .y = 10000 } }) |point| { + try exact(c.layoutTextCaretPositionForPoint(text, options, point), c.layoutTextCaretPositionForPoint(compiled_text, compiled_options, point)); + } + for ([_]usize{ 0, 1, 4, 400 }) |budget| { + var ar: [400]c.TextSelectionRect = undefined; + var br: [400]c.TextSelectionRect = undefined; + try exact(c.layoutTextSelectionRects(text, options, .{ .start = 1, .end = text.text.len }, ar[0..budget]), c.layoutTextSelectionRects(compiled_text, compiled_options, .{ .start = 1, .end = text.text.len }, br[0..budget])); + } +} +fn run(bytes: []const u8) c.DrawText { + return .{ .text = bytes, .size = 11.25, .font_id = c.default_sans_font_id, .origin = .{ .x = 3.25, .y = 14.5 }, .color = c.Color.rgba(1, 1, 1, 1) }; +} +test "compiled text runs preserve complete plain lines overflow alignment and streaming geometry" { + _ = core.initialModel(); + const inputs = [_][]const u8{ "", "a", "hello world", " lead\tspace tail ", "word\n\n line\n", "ab\r\ncd\ref", "Γ© πŸ™‚η•Œ\xff\x80\xc0!", "verylongunbrokenword" }; + for (inputs) |bytes| for (std.enums.values(c.TextWrap)) |wrap| for (std.enums.values(c.TextAlign)) |alignment| for (std.enums.values(c.TextOverflow)) |overflow| { + for ([_]f32{ 0, 1, 12.25, 38, 96, std.math.inf(f32), std.math.nan(f32) }) |width| { + try compare(run(bytes), .{ .max_width = width, .wrap = wrap, .alignment = alignment, .overflow = overflow }, 400); + } + }; + for ([_]usize{ 0, 1, 4 }) |capacity| try compare(run("line\n" ** 260), .{ .max_width = 60 }, capacity); +} +const Trace = struct { + const Call = struct { kind: u8, start_hash: u64, len: usize, font: u64, size: f32 }; + calls: [16000]Call = undefined, + len: usize = 0, + batched: bool = false, + ink_mode: u8 = 0, + fn record(self: *Trace, kind: u8, font: u64, size: f32, text: []const u8) void { + self.calls[self.len] = .{ .kind = kind, .font = font, .size = size, .len = text.len, .start_hash = std.hash.Wyhash.hash(0, text) }; + self.len += 1; + } + fn width(context: ?*anyopaque, font: u64, size: f32, bytes: []const u8) f32 { + const self: *Trace = @ptrCast(@alignCast(context.?)); + self.record(1, font, size, bytes); + var value: f32 = 0; + for (bytes) |byte| value += @as(f32, @floatFromInt(byte % 5 + 1)) * size / 8; + return value; + } + fn advances(context: ?*anyopaque, font: u64, size: f32, bytes: []const u8, out: []f32) bool { + const self: *Trace = @ptrCast(@alignCast(context.?)); + self.record(2, font, size, bytes); + if (!self.batched) return false; + for (bytes, out) |byte, *value| value.* = @as(f32, @floatFromInt(byte % 5 + 1)) * size / 8; + return true; + } + fn ink(context: ?*anyopaque, font: u64, size: f32, bytes: []const u8, out: *c.TextInkMetrics) bool { + const self: *Trace = @ptrCast(@alignCast(context.?)); + self.record(3, font, size, bytes); + out.* = .{ .min_x = -size * 0.5, .max_x = @as(f32, @floatFromInt(bytes.len)) * size, .min_y = -size * 0.75, .max_y = size * 1.5 }; + if (self.ink_mode == 2) out.max_x = std.math.nan(f32); + if (self.ink_mode == 3) out.max_x = out.min_x; + return self.ink_mode != 0; + } +}; +fn compareBounds(text: c.DrawText, batched: bool, ink_mode: u8, provider: bool) !void { + errdefer std.debug.print("ink case bytes={d} glyphs={d} size={d} layout={any} batched={} ink={d} provider={}\n", .{ text.text.len, text.glyphs.len, text.size, text.text_layout, batched, ink_mode, provider }); + var a: Trace = .{ .batched = batched, .ink_mode = ink_mode }; + var b: Trace = .{ .batched = batched, .ink_mode = ink_mode }; + const ap: c.TextMeasureProvider = .{ .context = &a, .measure_fn = Trace.width, .measure_advances_fn = Trace.advances, .measure_ink_fn = Trace.ink }; + const bp: c.TextMeasureProvider = .{ .context = &b, .measure_fn = Trace.width, .measure_advances_fn = Trace.advances, .measure_ink_fn = Trace.ink }; + var ta = text; + ta.measure = if (provider) &ap else null; + var tb = ta; + tb.measure = if (provider) &bp else null; + tb.text_run_policy = core.nativeWindowPolicy; + if (tb.text_layout) |*o| o.text_run_policy = core.nativeWindowPolicy; + c.bumpTextMeasureGeneration(); + const expected = (c.CanvasCommand{ .draw_text = ta }).bounds(); + c.bumpTextMeasureGeneration(); + const actual = (c.CanvasCommand{ .draw_text = tb }).bounds(); + try exact(expected, actual); + try exact(a.calls[0..a.len], b.calls[0..b.len]); + core.rt.frameReset(); + try exact(expected, actual); +} +test "compiled text bounds preserve complete metrics ink unions and callback order" { + _ = core.initialModel(); + for ([_][]const u8{ "", "a", "italic overhang ", "Γ©πŸ™‚\nlast", "line\n" ** 70 }) |bytes| { + for ([_]bool{ false, true }) |provider| for ([_]bool{ false, true }) |batched| for (0..4) |ink_mode| { + var text = run(bytes); + try compareBounds(text, batched, @intCast(ink_mode), provider); + for (std.enums.values(c.TextWrap)) |wrap| for ([_]f32{ 1, 20, 90 }) |width| { + text.text_layout = .{ .max_width = width, .wrap = wrap, .alignment = .center }; + try compareBounds(text, batched, @intCast(ink_mode), provider); + }; + }; + } + var shaped = run("Γ©πŸ™‚"); + shaped.glyphs = &.{ .{ .id = 1, .x = -3, .y = -2, .advance = 8 }, .{ .id = 2, .x = 12, .y = 4, .advance = 7 } }; + try compareBounds(shaped, false, 1, true); + shaped.text_layout = .{ .max_width = 10, .wrap = .none }; + try compareBounds(shaped, false, 1, true); +} +test "compiled text runs preserve complete width and advance capability call order" { + _ = core.initialModel(); + for ([_]bool{ false, true }) |batched| for (std.enums.values(c.TextWrap)) |wrap| for (std.enums.values(c.TextOverflow)) |overflow| { + var a: Trace = .{ .batched = batched }; + var b: Trace = .{ .batched = batched }; + const ap: c.TextMeasureProvider = .{ .context = &a, .measure_fn = Trace.width, .measure_advances_fn = Trace.advances }; + const bp: c.TextMeasureProvider = .{ .context = &b, .measure_fn = Trace.width, .measure_advances_fn = Trace.advances }; + var ta = run("lead words tail\n next\xff"); + ta.measure = ≈ + var tb = ta; + tb.measure = &bp; + tb.text_run_policy = core.nativeWindowPolicy; + const oa: c.TextLayoutOptions = .{ .max_width = 37.25, .measure = &ap, .wrap = wrap, .overflow = overflow }; + var ob = oa; + ob.measure = &bp; + ob.text_run_policy = core.nativeWindowPolicy; + var al: [100]c.TextLine = undefined; + var bl: [100]c.TextLine = undefined; + c.bumpTextMeasureGeneration(); + const expected = try c.layoutTextRun(ta, oa, &al); + c.bumpTextMeasureGeneration(); + const actual = try c.layoutTextRun(tb, ob, &bl); + try exact(expected, actual); + try exact(a.calls[0..a.len], b.calls[0..b.len]); + // Query the same already-materialized line so font prefix calls + // and cache reads compare independently from iterator traversal. + for (expected.lines, actual.lines) |el, rl| { + a.len = 0; + b.len = 0; + try exact(c.textLineCaretX(ta, el, 3), c.textLineCaretX(tb, rl, 3)); + try exact(a.calls[0..a.len], b.calls[0..b.len]); + } + core.rt.frameReset(); + try exact(expected, actual); + }; +} +test "compiled text runs preserve shaped explicit and proportional glyph layouts" { + _ = core.initialModel(); + const glyphs = [_]c.Glyph{ + .{ .id = 1, .x = -1.25, .y = 0.25, .advance = 8, .text_start = 0, .text_len = 2 }, + .{ .id = 2, .x = 7, .y = -2, .advance = 3, .text_start = 2, .text_len = 1 }, + .{ .id = 3, .x = 10, .y = 1, .advance = 14, .text_start = 3, .text_len = 4 }, + .{ .id = 4, .x = 24, .y = 0, .advance = 5, .text_start = 7, .text_len = 1 }, + .{ .id = 5, .x = 29, .y = 0, .advance = 20, .text_start = 8, .text_len = 3 }, + }; + var text = run("Γ© πŸ™‚ η•Œ"); + for ([_]bool{ false, true }) |explicit| { + var source = glyphs; + if (!explicit) for (&source) |*glyph| { + glyph.text_start = 0; + glyph.text_len = 0; + }; + text.glyphs = &source; + for (std.enums.values(c.TextWrap)) |wrap| for (std.enums.values(c.TextAlign)) |alignment| for (std.enums.values(c.TextOverflow)) |overflow| for ([_]f32{ 0, 1, 12, 30, 90 }) |width| { + try compare(text, .{ .wrap = wrap, .alignment = alignment, .overflow = overflow, .max_width = width }, 400); + }; + } +} +test "compiled text runs preserve exceptional numeric words and complete line failures" { + _ = core.initialModel(); + for ([_]u32{ 0, 0x80000000, 0x3f800000, 0xbf800000, 0x7f800000, 0xff800000, 0x7fc00037, 1 }) |word| { + var text = run("abc def"); + text.size = @bitCast(word); + for (std.enums.values(c.TextWrap)) |wrap| try compare(text, .{ .max_width = 12.25, .wrap = wrap, .line_height = @bitCast(word) }, 400); + } +} +test "text run copied transport rejects corrupt storage and capability ranges" { + var bytes: [527]u8 = @splat(0); + bytes[0..8].* = .{ 54, 1, 0, 0, 1, 0, 0, 0 }; + std.mem.writeInt(u32, bytes[8..12], 524, .little); + std.mem.writeInt(u32, bytes[12..16], bytes.len, .little); + std.mem.writeInt(u32, bytes[16..20], @bitCast(@as(f32, 12)), .little); + std.mem.writeInt(u32, bytes[28..32], @bitCast(@as(f32, 5)), .little); + std.mem.writeInt(u32, bytes[36..40], 3, .little); + @memcpy(bytes[524..], "abc"); + var reply: [512]u8 = undefined; + _ = core.initialModel(); + try std.testing.expectEqual(reply.len, core.nativeWindowPolicy(&bytes, &reply)); + try std.testing.expect(policy.resultValid(&bytes, &reply)); + for ([_]usize{ 0, 8, 12, 36, 104, 108, 376 }) |at| { + var damaged = reply; + damaged[at] ^= 0xff; + try std.testing.expect(!policy.resultValid(&bytes, &damaged)); + } + // A valid ink capability is invalid in line-planning mode. + var wrong_mode = reply; + std.mem.writeInt(u32, wrong_mode[96..100], 60, .little); + std.mem.writeInt(u32, wrong_mode[100..104], 6, .little); + try std.testing.expect(!policy.resultValid(&bytes, &wrong_mode)); + try std.testing.expect(!policy.resultValid(&bytes, reply[0..511])); +} +test "portable text planner context survives command copies without changing content identity" { + _ = core.initialModel(); + var commands: [2]c.CanvasCommand = undefined; + var builder = c.Builder.init(&commands); + builder.text_run_policy = core.nativeWindowPolicy; + const text = run("owned text"); + try builder.drawText(text); + const copy = commands[0].draw_text; + try std.testing.expect(copy.text_run_policy.? == core.nativeWindowPolicy); + var lines_a: [10]c.TextLine = undefined; + var lines_b: [10]c.TextLine = undefined; + const expected = try c.layoutTextRun(text, .{}, &lines_a); + const actual = try c.layoutTextRun(copy, .{}, &lines_b); + try exact(expected, actual); + const old_list: c.DisplayList = .{ .commands = &.{.{ .draw_text = text }} }; + const new_list = builder.displayList(); + var changes: [2]c.DiffChange = undefined; + try std.testing.expectEqual(@as(usize, 0), (try c.DisplayList.diff(old_list, new_list, &changes)).len); + var old_plans: [1]c.TextLayoutPlan = undefined; + var new_plans: [1]c.TextLayoutPlan = undefined; + try exact(try old_list.textLayoutPlan(.{}, &old_plans, &lines_a), try new_list.textLayoutPlan(.{}, &new_plans, &lines_b)); + core.rt.frameReset(); + try exact(expected, actual); +} diff --git a/tests/ts-core/typography_e2e_tests.zig b/tests/ts-core/typography_e2e_tests.zig index c0d83070e..d529f93fd 100644 --- a/tests/ts-core/typography_e2e_tests.zig +++ b/tests/ts-core/typography_e2e_tests.zig @@ -46,6 +46,7 @@ test "compiled model themes copy the complete register across frame and commit o reference.control_geometry_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; reference.paragraph_policy = core.nativeWindowPolicy; + reference.text_run_policy = core.nativeWindowPolicy; try std.testing.expectEqualDeep(reference, snapshot); const large = app.model.large; core.rt.frameReset(); @@ -110,6 +111,7 @@ fn restamp(reference: *canvas.DesignTokens, actual: canvas.DesignTokens) void { reference.control_geometry_policy = core.nativeWindowPolicy; reference.code_content_policy = core.nativeWindowPolicy; reference.paragraph_policy = core.nativeWindowPolicy; + reference.text_run_policy = core.nativeWindowPolicy; } test "compiled themes retain forced scheme, accessibility and complete-register precedence" { const app = try Adapter.create(std.testing.allocator, .{}, .{ .name = "theme-coordination", .scene = .{}, .canvas_label = "canvas", .view = view, .theme_state_fn = forcedDark }); diff --git a/tests/ts-core/widget_metric_e2e_tests.zig b/tests/ts-core/widget_metric_e2e_tests.zig index 7c3d2241a..4f30f577a 100644 --- a/tests/ts-core/widget_metric_e2e_tests.zig +++ b/tests/ts-core/widget_metric_e2e_tests.zig @@ -15,6 +15,7 @@ test { _ = @import("indicator_plan_e2e_tests.zig"); _ = @import("effect_plan_e2e_tests.zig"); _ = @import("paragraph_layout_e2e_tests.zig"); + _ = @import("text_run_layout_e2e_tests.zig"); } fn owned(tokens: c.DesignTokens) c.DesignTokens { var t = tokens; From 34aa01afd795df216d9a39b231cceff7654736cc Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 15:15:14 -0500 Subject: [PATCH 4/5] Isolate text planner range field names - Preserve app integer contracts while compiling text geometry across complete application graphs. - Keep planner transport, font capability ordering, and native parity unchanged. --- packages/core/src/text_run_layout.ts | 58 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/core/src/text_run_layout.ts b/packages/core/src/text_run_layout.ts index f68917721..2b276bb95 100644 --- a/packages/core/src/text_run_layout.ts +++ b/packages/core/src/text_run_layout.ts @@ -48,8 +48,8 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { const result = nscvSemanticDivide(product, nscvFlowSmall(denominator)); return result.quotient.low + result.quotient.high * 4294967296 + (ceil && (result.remainder.low !== 0 || result.remainder.high !== 0) ? 1 : 0); }; - const range = (start: number, length: number): { start: number; end: number } => { - if (textLength === 0 || glyphCount === 0) return { start: 0, end: 0 }; + const range = (start: number, length: number): { run_first_byte: number; run_last_byte: number } => { + if (textLength === 0 || glyphCount === 0) return { run_first_byte: 0, run_last_byte: 0 }; const end = Math.min(glyphCount, start + length), count = scalarCount(0, textLength); let a = textLength, b = 0, explicit = length > 0 && start < glyphCount; if (explicit) for (let n = start; n < end; n++) { @@ -57,11 +57,11 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { const at = glyphWord(n, 12), first = snap(at), last = snap(at + size); a = Math.min(a, first); b = Math.max(b, last); } - if (explicit) return { start: a, end: b }; - if (count === 0) return { start: 0, end: 0 }; - return { start: scalarOffset(0, textLength, Math.min(count, ratioIndex(start, count, glyphCount, false))), end: scalarOffset(0, textLength, Math.min(count, ratioIndex(end, count, glyphCount, true))) }; + if (explicit) return { run_first_byte: a, run_last_byte: b }; + if (count === 0) return { run_first_byte: 0, run_last_byte: 0 }; + return { run_first_byte: scalarOffset(0, textLength, Math.min(count, ratioIndex(start, count, glyphCount, false))), run_last_byte: scalarOffset(0, textLength, Math.min(count, ratioIndex(end, count, glyphCount, true))) }; }; - const glyphBreak = (n: number): boolean => { const r = range(n, 1); return r.start < r.end && isBreak(r.start); }; + const glyphBreak = (n: number): boolean => { const r = range(n, 1); return r.run_first_byte < r.run_last_byte && isBreak(r.run_first_byte); }; const trim = (a: number, b: number): number => { let p = b; while (p > a && isBreak(p - 1)) p--; return p === a ? b : p; }; const done = (): Uint8Array => { out[3] = 2; put(96, 0); put(100, 0); put(112, 0); return out; }; const ask = (phase: number, kind: number, a: number, b: number): Uint8Array => { @@ -122,7 +122,7 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { if (total <= sub(width, f(372))) { put(172, n); float(176, total); } put(152, n); phase = 18; }; - const lineRange = (): { start: number; end: number } => { const start = Math.min(u(320), textLength); return { start, end: Math.min(textLength, start + u(324)) }; }; + const lineRange = (): { run_first_byte: number; run_last_byte: number } => { const start = Math.min(u(320), textLength); return { run_first_byte: start, run_last_byte: Math.min(textLength, start + u(324)) }; }; const explicitLine = (): boolean => { if (u(332) === 0 || u(328) >= glyphCount) return false; for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) if (glyphWord(n, 16) === 0) return false; return true; }; const savedRawGlyphBounds = (): { dx: number; first: number } => { const x = f(336), bh = f(348), saved = out.slice(336, 352); @@ -164,7 +164,7 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { if (value > width) { end = index === start ? index + 1 : out[4] === 1 && last > start ? last : index; break; } total = value; index++; } - const r = range(start, end - start); setLine(r.start, r.end - r.start, start, end - start); + const r = range(start, end - start); setLine(r.run_first_byte, r.run_last_byte - r.run_first_byte, start, end - start); put(288, end); put(292, u(48) + 1); put(296, 0); if (!(out[4] === 0 && out[6] === 0 && width > 0 && width !== Infinity && end > start)) { phase = 30; continue; } total = 0; for (let n = start; n < end; n++) total = add(total, advance(n)); @@ -235,7 +235,7 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { let end = u(328), total = 0; const limit = u(328) + u(332); while (end < limit) { const value = add(total, advance(end)); if (value > sub(width, f(372))) break; total = value; end++; } while (end > u(328) && glyphBreak(end - 1)) end--; - const r = range(u(328), end - u(328)); put(360, Math.max(0, r.end - u(320))); put(368, end - u(328)); phase = 30; + const r = range(u(328), end - u(328)); put(360, Math.max(0, r.run_last_byte - u(320))); put(368, end - u(328)); phase = 30; } if (phase === 0 && mode === 4) phase = 30; if (phase === 30) { @@ -253,23 +253,23 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { } if (phase === 32) { float(344, f(116)); return finishBounds(); } if (phase === 0 && mode === 2) { - const r = lineRange(), offset = Math.max(r.start, Math.min(r.end, snap(u(56)))); - if (u(332) === 0 || u(328) >= glyphCount) return ask(40, 1, r.start, offset); + const r = lineRange(), offset = Math.max(r.run_first_byte, Math.min(r.run_last_byte, snap(u(56)))); + if (u(332) === 0 || u(328) >= glyphCount) return ask(40, 1, r.run_first_byte, offset); let x = f(336); - if (r.end > r.start && offset > r.start) { - if (offset >= r.end) x = add(f(336), f(344)); + if (r.run_last_byte > r.run_first_byte && offset > r.run_first_byte) { + if (offset >= r.run_last_byte) x = add(f(336), f(344)); else { const raw = savedRawGlyphBounds(); if (explicitLine()) { x = add(f(336), f(344)); for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) { - const gr = range(n, 1); if (gr.end <= r.start || gr.start >= r.end) continue; + const gr = range(n, 1); if (gr.run_last_byte <= r.run_first_byte || gr.run_first_byte >= r.run_last_byte) continue; const gx = glyphX(n, raw.first, raw.dx); - if (offset <= gr.start) { x = gx; break; } - if (offset < gr.end) { const count = scalarCount(gr.start, gr.end); let p = gr.start, index = 0; while (p < snap(offset)) { p = next(p); index++; } x = add(gx, mul(div(Math.fround(Math.min(index, count)), Math.fround(count)), max(1, advance(n)))); break; } + if (offset <= gr.run_first_byte) { x = gx; break; } + if (offset < gr.run_last_byte) { const count = scalarCount(gr.run_first_byte, gr.run_last_byte); let p = gr.run_first_byte, index = 0; while (p < snap(offset)) { p = next(p); index++; } x = add(gx, mul(div(Math.fround(Math.min(index, count)), Math.fround(count)), max(1, advance(n)))); break; } } } else { - const count = scalarCount(r.start, r.end); let p = r.start, index = 0; while (p < offset) { p = next(p); index++; } + const count = scalarCount(r.run_first_byte, r.run_last_byte); let p = r.run_first_byte, index = 0; while (p < offset) { p = next(p); index++; } const relative = count === 0 ? 0 : Math.min(u(332), ratioIndex(index, u(332), count, false)); x = relative === 0 ? f(336) : relative >= u(332) || u(328) + relative >= glyphCount ? add(f(336), f(344)) : glyphX(u(328) + relative, raw.first, raw.dx); } @@ -280,30 +280,30 @@ function nscvTextRunLayout(request: Uint8Array): Uint8Array { if (phase === 40) { const x = add(f(336), f(116)); float(304, u(356) !== 0 || u(364) !== 0 ? min(x, add(f(336), f(344))) : x); return done(); } if (phase === 0 && mode === 3) { const r = lineRange(), x = f(60), right = add(f(336), f(344)); - if (x <= f(336)) { put(308, r.start); return done(); } + if (x <= f(336)) { put(308, r.run_first_byte); return done(); } if (u(356) !== 0 || u(364) !== 0) { - if (x >= right) { put(308, r.end); return done(); } - if (x >= sub(right, f(372))) { put(308, snap(Math.min(r.end, u(320) + (u(356) !== 0 ? u(360) : u(324))))); return done(); } + if (x >= right) { put(308, r.run_last_byte); return done(); } + if (x >= sub(right, f(372))) { put(308, snap(Math.min(r.run_last_byte, u(320) + (u(356) !== 0 ? u(360) : u(324))))); return done(); } } if (u(332) > 0 && u(328) < glyphCount) { const raw = savedRawGlyphBounds(), explicit = explicitLine(); for (let n = u(328); n < Math.min(glyphCount, u(328) + u(332)); n++) { const gx = glyphX(n, raw.first, raw.dx), step = max(1, advance(n)), gr = range(n, 1); if (explicit) { - if (gr.end <= r.start || gr.start >= r.end) continue; - if (x <= gx) { put(308, Math.max(r.start, gr.start)); return done(); } + if (gr.run_last_byte <= r.run_first_byte || gr.run_first_byte >= r.run_last_byte) continue; + if (x <= gx) { put(308, Math.max(r.run_first_byte, gr.run_first_byte)); return done(); } if (x < add(gx, step)) { - const count = scalarCount(gr.start, gr.end), value = div(sub(x, gx), step), clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; - const index = Math.floor(add(mul(clamped, Math.fround(count)), 0.5)); put(308, scalarOffset(gr.start, gr.end, Math.min(index, count))); return done(); + const count = scalarCount(gr.run_first_byte, gr.run_last_byte), value = div(sub(x, gx), step), clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; + const index = Math.floor(add(mul(clamped, Math.fround(count)), 0.5)); put(308, scalarOffset(gr.run_first_byte, gr.run_last_byte, Math.min(index, count))); return done(); } - } else if (x < add(gx, mul(step, 0.5))) { put(308, Math.max(r.start, Math.min(r.end, snap(gr.start)))); return done(); } + } else if (x < add(gx, mul(step, 0.5))) { put(308, Math.max(r.run_first_byte, Math.min(r.run_last_byte, snap(gr.run_first_byte)))); return done(); } } - put(308, r.end); return done(); + put(308, r.run_last_byte); return done(); } - put(152, r.start); float(164, f(336)); phase = 41; + put(152, r.run_first_byte); float(164, f(336)); phase = 41; } - if (phase === 41) { const r = lineRange(); if (u(152) >= r.end) { put(308, r.end); return done(); } return ask(42, drawMeasure ? 1 : 2, drawMeasure ? r.start : u(152), next(u(152))); } - if (phase === 42) { float(176, f(116)); if (drawMeasure) return ask(43, 1, lineRange().start, u(152)); phase = 44; } + if (phase === 41) { const r = lineRange(); if (u(152) >= r.run_last_byte) { put(308, r.run_last_byte); return done(); } return ask(42, drawMeasure ? 1 : 2, drawMeasure ? r.run_first_byte : u(152), next(u(152))); } + if (phase === 42) { float(176, f(116)); if (drawMeasure) return ask(43, 1, lineRange().run_first_byte, u(152)); phase = 44; } if (phase === 43) { float(176, max(0, sub(f(176), f(116)))); phase = 44; } if (phase === 44) { const step = max(1, f(176)); if (f(60) < add(f(164), mul(step, 0.5))) { put(308, u(152)); return done(); } float(164, add(f(164), step)); put(152, next(u(152))); phase = 41; continue; } if (phase === 0 && (mode === 5 || mode === 8)) { From 09ec29443c942f1b61daa439a35d8ee4c488513a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 8 Oct 2026 17:50:19 -0500 Subject: [PATCH 5/5] Reduce repeated semantic policy calls - Share action and focusability results from one copied response. - Limit scroll observations to the queried widget while preserving full tree geometry. --- src/primitives/canvas/events.zig | 11 ++++ .../canvas/semantic_tree_policy.zig | 10 ++- tests/ts-core/semantic_tree_e2e_tests.zig | 65 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/primitives/canvas/events.zig b/src/primitives/canvas/events.zig index c799360da..0a056d408 100644 --- a/src/primitives/canvas/events.zig +++ b/src/primitives/canvas/events.zig @@ -2089,6 +2089,17 @@ pub fn defaultFocusable(widget: Widget) bool { }; } +/// Both facts share one portable response. Copy them together before any +/// further policy call can invalidate the compiler's borrowed result. +pub fn semanticActionsAndFocusable(widget: Widget) struct { actions: WidgetActions, focusable: bool } { + if (widget.state.disabled) return .{ .actions = .{}, .focusable = false }; + if (widget.interaction_policy != null) { + const result = compiledSemanticActions(widget); + return .{ .actions = result.actions, .focusable = result.focusable }; + } + return .{ .actions = semanticActions(widget), .focusable = defaultFocusable(widget) }; +} + // Explicit wire bits, independent of WidgetActions' native bool layout. const semantic_action_fields = [_][]const u8{ "focus", "press", "toggle", "increment", "decrement", "set_text", diff --git a/src/primitives/canvas/semantic_tree_policy.zig b/src/primitives/canvas/semantic_tree_policy.zig index 6e1115e27..1397bd981 100644 --- a/src/primitives/canvas/semantic_tree_policy.zig +++ b/src/primitives/canvas/semantic_tree_policy.zig @@ -37,6 +37,12 @@ pub const Plan = struct { putWord(request, 12, std.math.cast(u32, capacity) orelse std.math.maxInt(u32)); for (layout.nodes, 0..) |node, i| { const widget = node.widget; + // A metrics query emits only its target's semantic record. Other + // nodes supply geometry and tree facts for the extent walk. + const actions: @TypeOf(events.semanticActionsAndFocusable(widget)) = if (query == null or query == i) + events.semanticActionsAndFocusable(widget) + else + .{ .actions = widgets.WidgetActions{}, .focusable = false }; const bytes = request[16 + i * 128 ..][0..128]; const bounds = if (query == i and viewport != null) viewport.? else node.frame.inset(widget.layout.padding).normalized(); const chart_value: ?f32 = if (widget.chart.series.len > 0 and widget.chart.series[0].values.len > 0) widget.chart.series[0].values[widget.chart.series[0].values.len - 1] else null; @@ -47,8 +53,8 @@ pub const Plan = struct { @as(u32, if (widget.semantics.label.len > 0) 256 else 0) | @as(u32, if (widget.semantics.value != null) 512 else 0) | @as(u32, if (chart_value != null) 1024 else 0) | @as(u32, if (widget.semantics.list_item_index != null) 2048 else 0) | @as(u32, if (widget.semantics.list_item_count != null) 4096 else 0) | @as(u32, if (widget.semantics.focusable or widget.semantics.actions.focus) 8192 else 0) | - @as(u32, if (events.defaultFocusable(widget)) 16384 else 0); - for ([_]u32{ widgets.widgetKindCode(widget.kind), roleCode(widget.semantics.role), @intCast(node.depth), if (node.parent_index) |p| (if (p < count) @as(u32, @intCast(p)) else std.math.maxInt(u32)) else std.math.maxInt(u32), flags, stateBits(widget.state), events.semanticActionBits(events.semanticActions(widget)), widget.semantics.list_item_count orelse 0, widget.semantics.list_item_index orelse 0 }, 0..) |value, field| putWord(bytes, field * 4, value); + @as(u32, if (actions.focusable) 16384 else 0); + for ([_]u32{ widgets.widgetKindCode(widget.kind), roleCode(widget.semantics.role), @intCast(node.depth), if (node.parent_index) |p| (if (p < count) @as(u32, @intCast(p)) else std.math.maxInt(u32)) else std.math.maxInt(u32), flags, stateBits(widget.state), events.semanticActionBits(actions.actions), widget.semantics.list_item_count orelse 0, widget.semantics.list_item_index orelse 0 }, 0..) |value, field| putWord(bytes, field * 4, value); std.mem.writeInt(u64, bytes[36..44], widget.layout.columns, .little); std.mem.writeInt(u64, bytes[44..52], widget.children.len, .little); // A previously compiled virtual-flow operation supplies the diff --git a/tests/ts-core/semantic_tree_e2e_tests.zig b/tests/ts-core/semantic_tree_e2e_tests.zig index 07c8cdbdc..1da160b07 100644 --- a/tests/ts-core/semantic_tree_e2e_tests.zig +++ b/tests/ts-core/semantic_tree_e2e_tests.zig @@ -190,6 +190,71 @@ test "compiled semantics preserve authored roles all state bits values chart dat fn customVirtualExtent(_: canvas.Widget, viewport: f32) f32 { return viewport + 512.25; } + +var semantic_action_calls: usize = 0; +fn countedSemanticActions(request: []const u8, output: []u8) usize { + std.debug.assert(request.len == 6 and request[0] == 17); + semantic_action_calls += 1; + return core.nativeTextPolicy(request, output); +} + +test "compiled semantic collection shares one copied action and focusability response" { + _ = core.initialModel(); + defer core.rt.frameReset(); + for (std.enums.values(canvas.WidgetKind)) |kind| { + for (0..32) |bits| { + const widget = canvas.Widget{ + .id = 1, + .kind = kind, + .command = if (bits & 16 != 0) "activate" else "", + .state = .{ .disabled = bits & 1 != 0, .read_only = bits & 2 != 0 }, + .semantics = .{ + .role = if (bits & 4 != 0) .treeitem else .none, + .focusable = bits & 8 != 0, + .actions = .{ .focus = true, .press = true, .set_text = true, .drop_files = true, .dismiss = true }, + }, + }; + const expected_nodes = [_]canvas.WidgetLayoutNode{.{ .widget = widget, .frame = .init(0, 0, 240, 100), .depth = 0, .parent_index = null }}; + var actual_nodes = expected_nodes; + actual_nodes[0].widget.interaction_policy = countedSemanticActions; + const native = canvas.WidgetLayoutTree{ .nodes = &expected_nodes }; + const compiled = canvas.WidgetLayoutTree{ .nodes = &actual_nodes, .semantic_policy = core.nativeWindowPolicy }; + var expected: [1]canvas.WidgetSemanticsNode = undefined; + var actual: [1]canvas.WidgetSemanticsNode = undefined; + semantic_action_calls = 0; + try expectComplete(try native.collectSemantics(&expected), try compiled.collectSemantics(&actual)); + try std.testing.expectEqual(@as(usize, if (widget.state.disabled) 0 else 1), semantic_action_calls); + core.rt.frameReset(); + } + } +} + +test "compiled scroll observations retain both axes without querying unrelated widget actions" { + _ = core.initialModel(); + defer core.rt.frameReset(); + var expected_nodes = [_]canvas.WidgetLayoutNode{ + .{ .widget = .{ .id = 1, .kind = .scroll_view, .scroll_axes = .both }, .frame = .init(0, 0, 240, 100), .depth = 0, .parent_index = null }, + .{ .widget = .{ .id = 2, .kind = .text_field, .state = .{ .read_only = true }, .semantics = .{ .actions = .{ .set_text = true, .drop_files = true } } }, .frame = .init(0, 120, 320, 30), .depth = 1, .parent_index = 0 }, + .{ .widget = .{ .id = 3, .kind = .radio, .command = "activate" }, .frame = .init(0, 200, 300, 40), .depth = 1, .parent_index = 0 }, + .{ .widget = .{ .id = 4, .kind = .row, .semantics = .{ .role = .treeitem } }, .frame = .init(0, 250, 360, 30), .depth = 1, .parent_index = 0 }, + }; + for ([_]bool{ false, true }) |disabled| { + expected_nodes[2].widget.state.disabled = disabled; + var actual_nodes = expected_nodes; + for (&actual_nodes) |*node| node.widget.interaction_policy = countedSemanticActions; + const native = canvas.WidgetLayoutTree{ .nodes = &expected_nodes }; + const compiled = canvas.WidgetLayoutTree{ .nodes = &actual_nodes, .semantic_policy = core.nativeWindowPolicy }; + for (expected_nodes, 0..) |node, index| { + const viewport = node.frame.inset(node.widget.layout.padding).normalized(); + inline for (.{ canvas.ScrollAxis.vertical, canvas.ScrollAxis.horizontal }) |axis| { + semantic_action_calls = 0; + try expectComplete(canvas.widgetScrollAxisMetrics(native, index, canvas.virtualWidgetScrollContentExtent, axis, viewport), canvas.widgetScrollAxisMetrics(compiled, index, canvas.virtualWidgetScrollContentExtent, axis, viewport)); + try std.testing.expectEqual(@as(usize, if (node.widget.state.disabled) 0 else 1), semantic_action_calls); + core.rt.frameReset(); + } + } + } +} test "compiled axis queries honor custom virtual extent callbacks and supplied viewports" { _ = core.initialModel(); defer core.rt.frameReset();