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..7ea135b70 --- /dev/null +++ b/packages/core/src/text_run_layout.ts @@ -0,0 +1,384 @@ +// Scalar helpers need no continuation receiver or captured state. +function nscvTextRunAdd(a: number, b: number): number { return Math.fround(a + b); } +function nscvTextRunSubtract(a: number, b: number): number { return Math.fround(a - b); } +function nscvTextRunMultiply(a: number, b: number): number { return Math.fround(a * b); } +function nscvTextRunDivide(a: number, b: number): number { return Math.fround(a / b); } +function nscvTextRunMin(a: number, b: number): number { return nscvShellMin(a, b); } +function nscvTextRunMax(a: number, b: number): number { return nscvShellMax(a, b); } +function nscvTextRunSequenceLength(byte: number): number { return (byte & 128) === 0 ? 1 : (byte & 224) === 192 ? 2 : (byte & 240) === 224 ? 3 : (byte & 248) === 240 ? 4 : 1; } + +// 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 { + return new NscTextRunContext(request).run(); +} + +// One context owns each copied continuation; methods do not allocate captured +// helper closures when a font capability resumes the planner. +class NscTextRunContext { + readonly out: Uint8Array; + readonly w: DataView; + readonly facts: DataView; + readonly mode: number; + readonly size: number; + readonly ox: number; + readonly oy: number; + readonly width: number; + readonly textLength: number; + readonly glyphCount: number; + readonly advancesAt: number; + readonly textAt: number; + readonly text: Uint8Array; + readonly hasMeasure: boolean; + readonly drawMeasure: boolean; + phase: number; + private wholeScalarCount = -1; + + constructor(request: 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"); + const out = request.slice(0, 512); + this.out = out; + this.w = new DataView(out.buffer); + this.facts = new DataView(request.buffer, request.byteOffset, request.byteLength); + this.mode = out[2]!; + this.size = this.f(16); + this.ox = this.f(20); + this.oy = this.f(24); + this.width = this.f(28); + this.textLength = this.u(36); + this.glyphCount = this.u(40); + this.advancesAt = 512 + this.glyphCount * 32; + this.textAt = this.advancesAt + this.textLength * 4; + if (this.u(8) !== this.textAt || this.u(12) !== request.length || this.textAt + this.textLength !== request.length || this.u(52) > 1 || this.u(44) > (this.glyphCount > 0 && this.mode === 0 ? this.glyphCount : this.textLength)) + throw new Error("invalid text run storage"); + this.text = request.subarray(this.textAt); + this.hasMeasure = (out[7]! & 1) !== 0; + this.drawMeasure = (out[7]! & 2) !== 0; + for (let n = 0; n < this.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 (this.u(356) > 1 || this.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 (this.mode === 0 || this.mode === 1) for (let at = 320; at < 376; at++) if (out[at] !== 0) throw new Error("invalid initial text line"); + } + this.phase = this.u(96); + if (out[3] === 0 ? this.phase !== 0 : this.u(112) !== 1) throw new Error("invalid text continuation reply"); + if (this.phase !== 0 && (this.u(100) < 1 || this.u(100) > 7 || this.u(104) > this.u(108) || this.u(108) > this.textLength || this.u(120) > 1)) throw new Error("invalid text continuation capability"); + if (this.phase !== 0 && !(this.mode === 0 && (this.phase === 1 || this.phase === 3 || this.phase === 11 || this.phase === 12 || this.phase === 14 || this.phase === 15 || this.phase === 17 || this.phase === 19 || this.phase === 21 || this.phase === 22 || this.phase === 24 || this.phase === 31 || this.phase === 32) || this.mode === 1 && (this.phase === 1 || this.phase === 3) || this.mode === 2 && this.phase === 40 || this.mode === 3 && (this.phase === 42 || this.phase === 43) || this.mode === 4 && (this.phase === 31 || this.phase === 32) || this.mode === 5 && this.phase === 50 || this.mode === 6 && (this.phase === 60 || this.phase === 61) || this.mode === 8 && (this.phase === 50 || this.phase === 62))) throw new Error("invalid text mode continuation"); + if (this.phase !== 0) { + const kind = this.phase === 1 || this.phase === 12 ? 3 : this.phase === 11 || this.phase === 15 || this.phase === 24 ? 5 : this.phase === 19 || this.phase === 21 ? 2 : this.phase === 31 ? 4 : this.phase === 42 ? this.drawMeasure ? 1 : 2 : this.phase === 60 || this.phase === 62 ? 6 : this.phase === 61 ? 7 : 1; + if (this.u(100) !== kind || (kind === 3 || kind === 4) && (this.u(104) !== 0 || this.u(108) !== this.textLength) || (kind === 5 || kind === 7) && (this.u(104) !== 0 || this.u(108) !== 0)) throw new Error("invalid text phase capability"); + } + } + + u(at: number): number { return this.w.getUint32(at, true); } + f(at: number): number { return (at < 512 ? this.w : this.facts).getFloat32(at, true); } + put(at: number, value: number): void { this.w.setUint32(at, value, true); } + float(at: number, value: number): void { this.w.setFloat32(at, value, true); } + glyph(n: number, field: number): number { if (n >= this.glyphCount) throw new Error("invalid text glyph index"); return this.f(512 + n * 32 + field); } + glyphWord(n: number, field: number): number { if (n >= this.glyphCount) throw new Error("invalid text glyph index"); return this.facts.getUint32(512 + n * 32 + field, true); } + advance(n: number): number { return nscvTextRunMax(nscvTextRunMultiply(this.size, 0.25), this.glyph(n, 8)); } + snap(offset: number): number { let p = Math.min(offset, this.textLength); while (p > 0 && p < this.textLength && (this.text[p]! & 192) === 128) p--; return p; } + next(offset: number): number { const p = this.snap(offset); if (p >= this.textLength) return this.textLength; const end = Math.min(this.textLength, p + nscvTextRunSequenceLength(this.text[p]!)); return end <= offset ? Math.min(this.textLength, offset + 1) : end; } + isBreak(p: number): boolean { return this.text[p] === 32 || this.text[p] === 9; } + scalarCount(a: number, b: number): number { const text = this.text; let n = 0; while (a < b) { n++; a += Math.min(nscvTextRunSequenceLength(text[a]!), b - a); } return n; } + scalarOffset(a: number, b: number, count: number): number { let n = 0; while (a < b && n < count) { a = Math.min(b, this.next(a)); n++; } return a; } + // Preserve exact u64 product/division for proportional glyph ranges. + 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); + } + range(start: number, length: number): { run_first_byte: number; run_last_byte: number } { + if (this.textLength === 0 || this.glyphCount === 0) return { run_first_byte: 0, run_last_byte: 0 }; + const end = Math.min(this.glyphCount, start + length); + let a = this.textLength, b = 0, explicit = length > 0 && start < this.glyphCount; + if (explicit) for (let n = start; n < end; n++) { + const size = this.glyphWord(n, 16); if (size === 0) { explicit = false; break; } + const at = this.glyphWord(n, 12), first = this.snap(at), last = this.snap(at + size); + a = Math.min(a, first); b = Math.max(b, last); + } + if (explicit) return { run_first_byte: a, run_last_byte: b }; + // Explicit glyph ranges need no whole-run scan. Proportional fallback + // counts the immutable source once per copied continuation. + if (this.wholeScalarCount < 0) this.wholeScalarCount = this.scalarCount(0, this.textLength); + const count = this.wholeScalarCount; + if (count === 0) return { run_first_byte: 0, run_last_byte: 0 }; + return { run_first_byte: this.scalarOffset(0, this.textLength, Math.min(count, this.ratioIndex(start, count, this.glyphCount, false))), run_last_byte: this.scalarOffset(0, this.textLength, Math.min(count, this.ratioIndex(end, count, this.glyphCount, true))) }; + } + glyphBreak(n: number): boolean { const r = this.range(n, 1); return r.run_first_byte < r.run_last_byte && this.isBreak(r.run_first_byte); } + trim(a: number, b: number): number { let p = b; while (p > a && this.isBreak(p - 1)) p--; return p === a ? b : p; } + done(): Uint8Array { this.out[3] = 2; this.put(96, 0); this.put(100, 0); this.put(112, 0); return this.out; } + ask(phase: number, kind: number, a: number, b: number): Uint8Array { + if (a > b || b > this.textLength) throw new Error("invalid text capability range"); + this.put(96, phase); this.put(100, kind); this.put(104, a); this.put(108, b); this.put(112, 0); this.float(116, 0); this.out[3] = 1; return this.out; + } + rawGlyphBounds(start: number, length: number, baseline: number, height: number): void { + const end = Math.min(this.glyphCount, start + length), firstX = this.glyph(start, 0); + let minX = 0, maxX = this.advance(start), minY = nscvTextRunSubtract(baseline, this.size), maxY = nscvTextRunAdd(minY, height); + for (let n = start; n < end; n++) { + const x = nscvTextRunSubtract(this.glyph(n, 0), firstX), y = this.glyph(n, 4); + minX = nscvTextRunMin(minX, x); maxX = nscvTextRunMax(maxX, nscvTextRunAdd(x, this.advance(n))); + minY = nscvTextRunMin(minY, nscvTextRunSubtract(nscvTextRunAdd(baseline, y), this.size)); maxY = nscvTextRunMax(maxY, nscvTextRunAdd(nscvTextRunAdd(baseline, y), nscvTextRunMultiply(this.size, 0.25))); + } + this.float(336, nscvTextRunAdd(this.ox, minX)); this.float(340, minY); this.float(344, nscvTextRunMax(0, nscvTextRunSubtract(maxX, minX))); this.float(348, nscvTextRunMax(0, nscvTextRunSubtract(maxY, minY))); + } + finishBounds(): Uint8Array { + if (this.mode === 4) return this.done(); + this.float(344, nscvTextRunAdd(this.f(344), this.f(372))); + const limit = nscvTextRunMax(0, this.width); + if (!(limit <= 0 || this.f(344) >= limit)) { + const extra = nscvTextRunSubtract(limit, this.f(344)), dx = this.out[5] === 0 ? 0 : this.out[5] === 1 ? nscvTextRunMultiply(extra, 0.5) : extra; + this.float(336, nscvTextRunAdd(this.f(336), dx)); this.float(340, nscvTextRunAdd(this.f(340), 0)); + } + this.put(300, 1); return this.done(); + } + setLine(ts: number, tl: number, gs: number, gl: number): void { + this.put(320, ts); this.put(324, tl); this.put(328, gs); this.put(332, gl); + const height = this.f(32) > 0 ? this.f(32) : nscvTextRunMultiply(this.size, 1.25), baseline = nscvTextRunAdd(this.oy, nscvTextRunMultiply(Math.fround(this.u(48)), height)); + this.float(352, baseline); this.float(348, height); + } + endPlain(end: number): void { + this.put(148, end); + if (this.mode === 1) { this.put(308, end); this.phase = 99; return; } + let cursor = end; + const finished = end >= this.textLength; + if (!finished) { if (this.text[cursor] === 10) cursor++; else while (this.out[4] === 1 && cursor < this.textLength && this.isBreak(cursor)) cursor++; } + this.put(288, finished ? this.u(44) : cursor); this.put(292, this.u(48) + 1); this.put(296, finished ? 1 : 0); + this.setLine(this.u(144), end - this.u(144), this.u(144), end - this.u(144)); this.phase = 10; + } + takeBreakWidth(value: number): void { + const p = this.u(152), n = this.next(p); if (this.isBreak(p)) { this.put(156, n); this.put(160, 1); } + const limit = this.width > 0 ? this.width : Infinity; + if (value > limit) this.endPlain(p === this.u(144) ? n : this.out[4] === 1 && this.u(160) !== 0 && this.u(156) > this.u(144) ? this.trim(this.u(144), this.u(156)) : p); + else { this.float(164, value); this.put(152, n); this.phase = 2; } + } + elided(length: number, painted: number): void { this.put(356, 1); this.put(360, length); this.float(184, painted); this.put(188, 1); this.phase = 30; } + takeElisionAdvance(value: number): void { + const p = this.u(152), n = this.next(p), total = nscvTextRunAdd(this.f(164), value); this.float(164, total); + if (total <= nscvTextRunSubtract(this.width, this.f(372))) { this.put(172, n); this.float(176, total); } + this.put(152, n); this.phase = 18; + } + lineRange(): { run_first_byte: number; run_last_byte: number } { const start = Math.min(this.u(320), this.textLength); return { run_first_byte: start, run_last_byte: Math.min(this.textLength, start + this.u(324)) }; } + explicitLine(): boolean { if (this.u(332) === 0 || this.u(328) >= this.glyphCount) return false; for (let n = this.u(328); n < Math.min(this.glyphCount, this.u(328) + this.u(332)); n++) if (this.glyphWord(n, 16) === 0) return false; return true; } + savedRawGlyphBounds(): { dx: number; first: number } { + const x = this.f(336), bh = this.f(348), saved = this.out.slice(336, 352); + this.rawGlyphBounds(this.u(328), this.u(332), this.f(352), bh); const rawX = this.f(336); + this.out.set(saved, 336); + return { dx: nscvTextRunSubtract(x, rawX), first: this.glyph(this.u(328), 0) }; + } + glyphX(n: number, first: number, dx: number): number { return nscvTextRunAdd(nscvTextRunSubtract(nscvTextRunAdd(this.ox, this.glyph(n, 0)), first), dx); } + inflate(): void { + const em = nscvTextRunMax(0, this.size), left = nscvTextRunMultiply(em, 0.1), top = nscvTextRunMultiply(em, 0.35); + this.float(336, nscvTextRunSubtract(this.f(336), left)); this.float(340, nscvTextRunSubtract(this.f(340), top)); + this.float(344, nscvTextRunAdd(nscvTextRunAdd(this.f(344), left), top)); this.float(348, nscvTextRunAdd(nscvTextRunAdd(this.f(348), top), left)); + } + unionInk(x: number, y: number): void { + if (this.u(120) === 0) return; + const bx = nscvTextRunAdd(x, this.f(124)), by = nscvTextRunSubtract(y, this.f(136)), bw = nscvTextRunSubtract(this.f(132), this.f(124)), bh = nscvTextRunSubtract(this.f(136), this.f(128)); + const emptyA = this.f(344) <= 0 || this.f(348) <= 0, emptyB = bw <= 0 || bh <= 0; + if (emptyA && emptyB) { for (let at = 336; at < 352; at += 4) this.float(at, 0); return; } + if (emptyB) return; + if (emptyA) { this.float(336, bx); this.float(340, by); this.float(344, bw); this.float(348, bh); return; } + const x0 = nscvTextRunMin(this.f(336), bx), y0 = nscvTextRunMin(this.f(340), by), x1 = nscvTextRunMax(nscvTextRunAdd(this.f(336), this.f(344)), nscvTextRunAdd(bx, bw)), y1 = nscvTextRunMax(nscvTextRunAdd(this.f(340), this.f(348)), nscvTextRunAdd(by, bh)); + this.float(336, x0); this.float(340, y0); this.float(344, nscvTextRunSubtract(x1, x0)); this.float(348, nscvTextRunSubtract(y1, y0)); + } + + run(): Uint8Array { + while (true) { + if (this.phase === 99) return this.done(); + if (this.phase === 0 && (this.mode === 0 || this.mode === 1)) { + this.put(288, this.u(44)); this.put(292, this.u(48)); this.put(296, this.u(52)); + if (this.mode === 0 && this.u(52) !== 0) return this.done(); + this.put(144, this.u(44)); this.put(152, this.u(44)); + if (this.mode === 0 && this.glyphCount > 0) { + let start = this.u(44); while (this.out[4] === 1 && start < this.glyphCount && this.glyphBreak(start)) start++; + if (start >= this.glyphCount) { + this.put(296, 1); if (this.u(48) > 0) return this.done(); this.setLine(0, 0, 0, 0); this.put(292, 1); this.phase = 30; continue; + } + let end = this.glyphCount, index = start, total = 0, last = -1; + if (this.out[4] !== 0 && this.width > 0 && this.width !== Infinity) while (index < this.glyphCount) { + if (this.glyphBreak(index)) last = index; + const value = nscvTextRunAdd(total, this.advance(index)); + if (value > this.width) { end = index === start ? index + 1 : this.out[4] === 1 && last > start ? last : index; break; } + total = value; index++; + } + const r = this.range(start, end - start); this.setLine(r.run_first_byte, r.run_last_byte - r.run_first_byte, start, end - start); + this.put(288, end); this.put(292, this.u(48) + 1); this.put(296, 0); + if (!(this.out[4] === 0 && this.out[6] === 0 && this.width > 0 && this.width !== Infinity && end > start)) { this.phase = 30; continue; } + total = 0; for (let n = start; n < end; n++) total = nscvTextRunAdd(total, this.advance(n)); + if (total <= nscvTextRunAdd(this.width, 0.125)) { this.phase = 30; continue; } + return this.ask(24, 5, 0, 0); + } + if (this.textLength === 0) { this.endPlain(0); this.phase = this.mode === 1 ? 99 : 30; continue; } + if (this.out[4] === 0 || !(this.width > 0) || this.width === Infinity) { let end = this.u(144); while (end < this.textLength && this.text[end] !== 10) end++; this.endPlain(end); continue; } + if (this.hasMeasure) return this.ask(1, 3, 0, this.textLength); + this.phase = 2; + } + if (this.phase === 1) { this.put(168, this.u(120)); this.phase = 2; } + if (this.phase === 2) { + const p = this.u(152); if (p >= this.textLength || this.text[p] === 10) { this.endPlain(p); continue; } + if (this.u(168) !== 0) { this.takeBreakWidth(nscvTextRunAdd(this.f(164), this.f(this.advancesAt + p * 4))); continue; } + return this.ask(3, 1, this.u(144), this.next(p)); + } + if (this.phase === 3) { this.takeBreakWidth(this.f(116)); continue; } + if (this.phase === 10) { + if (!(this.out[4] === 0 && this.out[6] === 0 && this.width > 0 && this.width !== Infinity && this.u(148) > this.u(144))) { this.phase = 30; continue; } + if (this.hasMeasure) return this.ask(12, 3, 0, this.textLength); + this.put(204, 2); return this.ask(11, 5, 0, 0); + } + if (this.phase === 12) { + if (this.u(120) !== 0) { this.put(204, 1); return this.ask(11, 5, 0, 0); } + return this.ask(14, 1, this.u(144), this.u(148)); + } + if (this.phase === 14) { + if (this.f(116) <= nscvTextRunAdd(this.width, 0.125)) { this.float(184, this.f(116)); this.put(188, 1); this.phase = 30; continue; } + return this.ask(15, 5, 0, 0); + } + if (this.phase === 15) { + this.float(372, this.f(116)); + if (this.f(372) > this.width) { this.float(372, 0); this.elided(0, 0); continue; } + this.put(152, this.u(144)); this.put(172, this.u(144)); this.float(176, 0); this.phase = 16; + } + if (this.phase === 16) { if (this.u(152) >= this.u(148)) { this.phase = 23; continue; } return this.ask(17, 1, this.u(144), this.next(this.u(152))); } + if (this.phase === 17) { + if (this.f(116) > nscvTextRunSubtract(this.width, this.f(372))) { this.phase = 23; continue; } + this.put(152, this.next(this.u(152))); this.put(172, this.u(152)); this.float(176, this.f(116)); this.phase = 16; continue; + } + if (this.phase === 23) { + let end = this.u(172); while (end > this.u(144) && this.isBreak(end - 1)) end--; + this.put(172, end); if (end === this.u(152)) { this.elided(end - this.u(144), this.f(176)); continue; } + return this.ask(22, 1, this.u(144), end); + } + if (this.phase === 22) { this.elided(this.u(172) - this.u(144), this.f(116)); continue; } + if (this.phase === 11) { this.float(372, this.f(116)); this.put(152, this.u(144)); this.put(172, this.u(144)); this.float(164, 0); this.float(176, 0); this.phase = 18; } + if (this.phase === 18) { + if (this.u(152) < this.u(148)) { + if (this.u(204) === 1) { this.takeElisionAdvance(this.f(this.advancesAt + this.u(152) * 4)); continue; } + return this.ask(19, 2, this.u(152), this.next(this.u(152))); + } + if (this.f(164) <= nscvTextRunAdd(this.width, 0.125)) { this.float(372, 0); this.float(184, this.f(164)); this.put(188, 1); this.phase = 30; continue; } + if (this.f(372) > this.width) { this.float(372, 0); this.elided(0, 0); continue; } + this.float(184, this.f(176)); this.phase = 20; + } + if (this.phase === 19) { this.takeElisionAdvance(this.f(116)); continue; } + if (this.phase === 20) { + const p = this.u(172); if (p <= this.u(144) || !this.isBreak(p - 1)) { this.elided(p - this.u(144), nscvTextRunMax(0, this.f(184))); continue; } + if (this.u(204) === 1) { this.float(184, nscvTextRunSubtract(this.f(184), this.f(this.advancesAt + (p - 1) * 4))); this.put(172, p - 1); continue; } + return this.ask(21, 2, p - 1, p); + } + if (this.phase === 21) { this.float(184, nscvTextRunSubtract(this.f(184), this.f(116))); this.put(172, this.u(172) - 1); this.phase = 20; continue; } + if (this.phase === 24) { + this.float(372, this.f(116)); this.put(356, 1); this.put(364, 1); + if (this.f(372) > this.width) { this.put(360, 0); this.put(368, 0); this.float(372, 0); this.phase = 30; continue; } + let end = this.u(328), total = 0; const limit = this.u(328) + this.u(332); + while (end < limit) { const value = nscvTextRunAdd(total, this.advance(end)); if (value > nscvTextRunSubtract(this.width, this.f(372))) break; total = value; end++; } + while (end > this.u(328) && this.glyphBreak(end - 1)) end--; + const r = this.range(this.u(328), end - this.u(328)); this.put(360, Math.max(0, r.run_last_byte - this.u(320))); this.put(368, end - this.u(328)); this.phase = 30; + } + if (this.phase === 0 && this.mode === 4) this.phase = 30; + if (this.phase === 30) { + const gs = this.u(328), gl = this.mode === 4 ? this.u(332) : this.u(364) !== 0 ? this.u(368) : this.u(332); + if (gl > 0 && gs < this.glyphCount) { this.rawGlyphBounds(gs, gl, this.f(352), this.f(348)); return this.finishBounds(); } + this.float(336, this.ox); this.float(340, nscvTextRunSubtract(this.f(352), this.size)); + if (this.u(188) !== 0 && this.mode === 0) { this.float(344, this.f(184)); return this.finishBounds(); } + if (this.drawMeasure) return this.ask(31, 4, 0, this.textLength); + return this.ask(32, 1, this.u(320), Math.min(this.textLength, this.u(320) + (this.u(356) !== 0 ? this.u(360) : this.u(324)))); + } + if (this.phase === 31) { + if (this.u(120) === 0) return this.ask(32, 1, this.u(320), Math.min(this.textLength, this.u(320) + (this.u(356) !== 0 ? this.u(360) : this.u(324)))); + let total = 0; const end = Math.min(this.textLength, this.u(320) + (this.u(356) !== 0 ? this.u(360) : this.u(324))); + for (let n = this.u(320); n < end; n++) total = nscvTextRunAdd(total, this.f(this.advancesAt + n * 4)); this.float(344, total); return this.finishBounds(); + } + if (this.phase === 32) { this.float(344, this.f(116)); return this.finishBounds(); } + if (this.phase === 0 && this.mode === 2) { + const r = this.lineRange(), offset = Math.max(r.run_first_byte, Math.min(r.run_last_byte, this.snap(this.u(56)))); + if (this.u(332) === 0 || this.u(328) >= this.glyphCount) return this.ask(40, 1, r.run_first_byte, offset); + let x = this.f(336); + if (r.run_last_byte > r.run_first_byte && offset > r.run_first_byte) { + if (offset >= r.run_last_byte) x = nscvTextRunAdd(this.f(336), this.f(344)); + else { + const raw = this.savedRawGlyphBounds(); + if (this.explicitLine()) { + x = nscvTextRunAdd(this.f(336), this.f(344)); + for (let n = this.u(328); n < Math.min(this.glyphCount, this.u(328) + this.u(332)); n++) { + const gr = this.range(n, 1); if (gr.run_last_byte <= r.run_first_byte || gr.run_first_byte >= r.run_last_byte) continue; + const gx = this.glyphX(n, raw.first, raw.dx); + if (offset <= gr.run_first_byte) { x = gx; break; } + if (offset < gr.run_last_byte) { const count = this.scalarCount(gr.run_first_byte, gr.run_last_byte); let p = gr.run_first_byte, index = 0; while (p < this.snap(offset)) { p = this.next(p); index++; } x = nscvTextRunAdd(gx, nscvTextRunMultiply(nscvTextRunDivide(Math.fround(Math.min(index, count)), Math.fround(count)), nscvTextRunMax(1, this.advance(n)))); break; } + } + } else { + const count = this.scalarCount(r.run_first_byte, r.run_last_byte); let p = r.run_first_byte, index = 0; while (p < offset) { p = this.next(p); index++; } + const relative = count === 0 ? 0 : Math.min(this.u(332), this.ratioIndex(index, this.u(332), count, false)); + x = relative === 0 ? this.f(336) : relative >= this.u(332) || this.u(328) + relative >= this.glyphCount ? nscvTextRunAdd(this.f(336), this.f(344)) : this.glyphX(this.u(328) + relative, raw.first, raw.dx); + } + } + } + this.float(304, this.u(356) !== 0 || this.u(364) !== 0 ? nscvTextRunMin(x, nscvTextRunAdd(this.f(336), this.f(344))) : x); return this.done(); + } + if (this.phase === 40) { const x = nscvTextRunAdd(this.f(336), this.f(116)); this.float(304, this.u(356) !== 0 || this.u(364) !== 0 ? nscvTextRunMin(x, nscvTextRunAdd(this.f(336), this.f(344))) : x); return this.done(); } + if (this.phase === 0 && this.mode === 3) { + const r = this.lineRange(), x = this.f(60), right = nscvTextRunAdd(this.f(336), this.f(344)); + if (x <= this.f(336)) { this.put(308, r.run_first_byte); return this.done(); } + if (this.u(356) !== 0 || this.u(364) !== 0) { + if (x >= right) { this.put(308, r.run_last_byte); return this.done(); } + if (x >= nscvTextRunSubtract(right, this.f(372))) { this.put(308, this.snap(Math.min(r.run_last_byte, this.u(320) + (this.u(356) !== 0 ? this.u(360) : this.u(324))))); return this.done(); } + } + if (this.u(332) > 0 && this.u(328) < this.glyphCount) { + const raw = this.savedRawGlyphBounds(), explicit = this.explicitLine(); + for (let n = this.u(328); n < Math.min(this.glyphCount, this.u(328) + this.u(332)); n++) { + const gx = this.glyphX(n, raw.first, raw.dx), step = nscvTextRunMax(1, this.advance(n)), gr = this.range(n, 1); + if (explicit) { + if (gr.run_last_byte <= r.run_first_byte || gr.run_first_byte >= r.run_last_byte) continue; + if (x <= gx) { this.put(308, Math.max(r.run_first_byte, gr.run_first_byte)); return this.done(); } + if (x < nscvTextRunAdd(gx, step)) { + const count = this.scalarCount(gr.run_first_byte, gr.run_last_byte), value = nscvTextRunDivide(nscvTextRunSubtract(x, gx), step), clamped = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0; + const index = Math.floor(nscvTextRunAdd(nscvTextRunMultiply(clamped, Math.fround(count)), 0.5)); this.put(308, this.scalarOffset(gr.run_first_byte, gr.run_last_byte, Math.min(index, count))); return this.done(); + } + } else if (x < nscvTextRunAdd(gx, nscvTextRunMultiply(step, 0.5))) { this.put(308, Math.max(r.run_first_byte, Math.min(r.run_last_byte, this.snap(gr.run_first_byte)))); return this.done(); } + } + this.put(308, r.run_last_byte); return this.done(); + } + this.put(152, r.run_first_byte); this.float(164, this.f(336)); this.phase = 41; + } + if (this.phase === 41) { const r = this.lineRange(); if (this.u(152) >= r.run_last_byte) { this.put(308, r.run_last_byte); return this.done(); } return this.ask(42, this.drawMeasure ? 1 : 2, this.drawMeasure ? r.run_first_byte : this.u(152), this.next(this.u(152))); } + if (this.phase === 42) { this.float(176, this.f(116)); if (this.drawMeasure) return this.ask(43, 1, this.lineRange().run_first_byte, this.u(152)); this.phase = 44; } + if (this.phase === 43) { this.float(176, nscvTextRunMax(0, nscvTextRunSubtract(this.f(176), this.f(116)))); this.phase = 44; } + if (this.phase === 44) { const step = nscvTextRunMax(1, this.f(176)); if (this.f(60) < nscvTextRunAdd(this.f(164), nscvTextRunMultiply(step, 0.5))) { this.put(308, this.u(152)); return this.done(); } this.float(164, nscvTextRunAdd(this.f(164), step)); this.put(152, this.next(this.u(152))); this.phase = 41; continue; } + if (this.phase === 0 && (this.mode === 5 || this.mode === 8)) { + if (this.glyphCount === 0 && this.textLength === 0) return this.done(); + if (this.glyphCount === 0) return this.ask(50, 1, 0, this.textLength); + let minX = nscvTextRunAdd(this.ox, this.glyph(0, 0)), maxX = nscvTextRunAdd(minX, this.advance(0)), minY = nscvTextRunSubtract(nscvTextRunAdd(this.oy, this.glyph(0, 4)), this.size), maxY = nscvTextRunAdd(nscvTextRunAdd(this.oy, this.glyph(0, 4)), nscvTextRunMultiply(this.size, 0.25)); + for (let n = 1; n < this.glyphCount; n++) { + const x = nscvTextRunAdd(this.ox, this.glyph(n, 0)), y = nscvTextRunAdd(this.oy, this.glyph(n, 4)); + minX = nscvTextRunMin(minX, x); maxX = nscvTextRunMax(maxX, nscvTextRunAdd(x, this.advance(n))); minY = nscvTextRunMin(minY, nscvTextRunSubtract(y, this.size)); maxY = nscvTextRunMax(maxY, nscvTextRunAdd(y, nscvTextRunMultiply(this.size, 0.25))); + } + this.float(336, minX); this.float(340, minY); this.float(344, nscvTextRunMax(nscvTextRunMultiply(this.size, 0.25), nscvTextRunSubtract(maxX, minX))); this.float(348, nscvTextRunMax(nscvTextRunMultiply(this.size, 1.25), nscvTextRunSubtract(maxY, minY))); + if (this.mode === 8) this.inflate(); this.put(300, 1); return this.done(); + } + if (this.phase === 50) { + this.float(336, this.ox); this.float(340, nscvTextRunSubtract(this.oy, this.size)); this.float(344, nscvTextRunMax(nscvTextRunMultiply(this.size, 0.25), nscvTextRunSubtract(nscvTextRunAdd(this.ox, this.f(116)), this.ox))); this.float(348, nscvTextRunMax(nscvTextRunMultiply(this.size, 1.25), nscvTextRunSubtract(nscvTextRunAdd(this.oy, nscvTextRunMultiply(this.size, 0.25)), nscvTextRunSubtract(this.oy, this.size)))); + this.put(300, 1); + if (this.mode === 8) { this.inflate(); if (this.drawMeasure) return this.ask(62, 6, 0, this.textLength); } + return this.done(); + } + if (this.phase === 0 && this.mode === 6) { + this.put(300, 1); if (this.glyphCount > 0 || !this.drawMeasure) return this.done(); + this.float(220, this.f(336)); this.float(224, nscvTextRunAdd(this.f(336), this.f(344))); + const start = Math.min(this.u(320), this.textLength), end = Math.min(this.textLength, start + (this.u(356) !== 0 ? this.u(360) : this.u(324))); + if (end > start) return this.ask(60, 6, start, end); + this.phase = 63; + } + if (this.phase === 60) { this.unionInk(this.f(220), this.f(352)); this.phase = 63; } + if (this.phase === 63) { if ((this.u(356) !== 0 || this.u(364) !== 0) && this.f(372) > 0) return this.ask(61, 7, 0, 0); return this.done(); } + if (this.phase === 61) { this.unionInk(nscvTextRunSubtract(this.f(224), this.f(372)), this.f(352)); return this.done(); } + if (this.phase === 62) { this.unionInk(this.ox, this.oy); return this.done(); } + if (this.phase === 0 && this.mode === 7) { this.inflate(); this.put(300, 1); return this.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;