Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
384 changes: 384 additions & 0 deletions packages/core/src/text_run_layout.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/core/src/view_frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") +
Expand Down
87 changes: 87 additions & 0 deletions packages/core/test/text_run_layout.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
});
2 changes: 1 addition & 1 deletion packages/core/test/view_frontend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/primitives/canvas/commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/primitives/canvas/layout_audit.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
32 changes: 30 additions & 2 deletions src/primitives/canvas/text_layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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| {
Expand All @@ -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;
Expand All @@ -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| {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading