diff --git a/build.zig b/build.zig
index 938eeab82..a860ea831 100644
--- a/build.zig
+++ b/build.zig
@@ -1585,6 +1585,7 @@ pub fn build(b: *std.Build) void {
b.step("test-ts-static-markup-specialized", "Compare static specialized metadata, forwarded handlers, tab triggers and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-specialized-tests", &.{ "static markup", "compiled markup values", "compiled markup metadata", "compiled markup options", "compiled markup postprocessing", "compiled markup numeric", "compiled markup admission", "compiled markup literals" })).step);
b.step("test-ts-static-markup-typography", "Compare static spans, interpolation, callback order and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-typography-tests", &.{ "static markup", "compiled markup specialized", "compiled markup runtime" })).step);
b.step("test-ts-static-markup-builders", "Compare static forms, timelines, charts, video and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-builder-tests", &.{ "static markup builders", "compiled markup specialized" })).step);
+ b.step("test-ts-static-markup-content", "Compare static markdown, code and stepper construction and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-content-tests", &.{ "static markup content builders", "compiled markup specialized" })).step);
b.step("test-ts-markup-postprocessing", "Compare forwarded handlers, provenance, tabs and borrowed binding paths").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-postprocessing-tests", &.{ "compiled markup postprocessing", "compiled markup literals", "compiled markup admission", "compiled markup construction" })).step);
b.step("test-ts-markup-literals", "Compare exact literal inference, template defaults and lexical capture ownership").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-literal-tests", &.{ "compiled markup literals", "compiled markup admission", "compiled markup construction" })).step);
b.step("test-ts-markup-admission", "Compare specialized attribute forms, raw flags and complete eager failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-admission-tests", &.{ "compiled markup admission", "compiled markup specialized", "compiled markup options", "compiled markup metadata" })).step);
diff --git a/packages/core/src/markup_specialized.ts b/packages/core/src/markup_specialized.ts
index 1584ffbfb..52f98d4e4 100644
--- a/packages/core/src/markup_specialized.ts
+++ b/packages/core/src/markup_specialized.ts
@@ -60,6 +60,24 @@ function nscsNode(r: NscElementReader, out: number[], mode: number): void {
nscsStaticAttrs(node, out, ["src", "controls", "autoplay", "loop", "muted", "width", "height", "grow", "label", "key", "global-key"]);
nsceEmit(out, 200); return;
}
+ if (mode === 29) {
+ nscsStaticAttrs(node, out, ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"]);
+ nsceEmit(out, 200); return;
+ }
+ if (mode === 30) {
+ nscsStaticAttrs(node, out, ["source", "language", "line-numbers", "added-lines", "removed-lines", "editable", "on-input", "wrap", "width", "height", "min-width", "grow", "label", "key", "global-key"]);
+ nsceEmit(out, 200); return;
+ }
+ if (mode === 31) {
+ // Inherited slot steps interpolate in their lexical site scope; ordinary
+ // steps use the stepper's scope. The step array precedes every label.
+ const steps = r.word(), slot = r.flag();
+ if (slot ? node.children.length !== 1 : steps !== node.children.length) throw new Error("invalid static stepper facts");
+ nscsStaticAttrs(node, out, ["active", "key", "global-key", "label"]);
+ nsceEmit(out, 100);
+ for (let i = 0; i < steps; i++) nsceEmit(out, slot ? 102 : 101, 0, i);
+ nsceEmit(out, 200); return;
+ }
if (mode === 2) {
if (node.children.length !== 0) { nsceFail(out, 1, 1); return; }
if (!nscsAttrs(node, out, ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"], 2, true)) return;
@@ -192,7 +210,7 @@ function nscsNode(r: NscElementReader, out: number[], mode: number): void {
throw new Error("invalid specialized node mode");
}
function nscvMarkupSpecialized(input: Uint8Array): Uint8Array {
- if (input.length < 4 || input[0] !== 112 || input[1] !== 1 || input[2]! > 28 || input[3]! > 1) throw new Error("invalid specialized header");
+ if (input.length < 4 || input[0] !== 112 || input[1] !== 1 || input[2]! > 31 || input[3]! > 1) throw new Error("invalid specialized header");
const r = new NscElementReader(input), out: number[] = [], mode = input[2]!;
if (mode === 15) {
const count = r.word(), expected = r.word();
diff --git a/packages/core/test/markup_specialized.test.ts b/packages/core/test/markup_specialized.test.ts
index 6569ca8a8..1e3bf4920 100644
--- a/packages/core/test/markup_specialized.test.ts
+++ b/packages/core/test/markup_specialized.test.ts
@@ -141,3 +141,41 @@ test("runtime specialized builders retain source order and repeated attributes",
[11, "series", ["label", "values", "label"], [], [4, 2, 4, 200]],
] as const) assert.deepEqual(actions(new Packet(mode).node(name, attrs(...names), [...children])), expected);
});
+
+test("static markdown code and stepper programs fix source and active before first-occurrence channels", () => {
+ const specs = [
+ [29, "markdown", ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"], [], [200]],
+ [30, "code", ["source", "language", "line-numbers", "added-lines", "removed-lines", "editable", "on-input", "wrap", "width", "height", "min-width", "grow", "label", "key", "global-key"], [], [200]],
+ [31, "stepper", ["active", "key", "global-key", "label"], [{ name: "step" }, { name: "step" }], [100, 101, 101, 200]],
+ ] as const;
+ for (const [mode, name, names, children, tail] of specs) {
+ const facts = mode === 31 ? [children.length, 0] : [];
+ const all = (1 << names.length) - 1;
+ const masks = [1, all, (all & 0x5555) | 1, all & 0x2aaa | 1, ...names.flatMap((_, i) => [1 | (1 << i), all ^ (i === 0 ? 0 : 1 << i)])];
+ for (const mask of masks) {
+ const selected = names.filter((_, i) => (mask & (1 << i)) !== 0).reverse();
+ const rows = new Packet(mode).node(name, attrs("kind", ...selected, ...selected), [...children]).word(...facts).actions();
+ const channels = names.flatMap((_, i) => (mask & (1 << i)) !== 0 ? [i + 1] : []);
+ assert.deepEqual(rows.map(row => row.action), [...channels, ...tail]);
+ for (const row of rows.slice(0, channels.length)) assert.equal(row.fields[0], selected.indexOf(names[row.action - 1]!) + 1);
+ if (mode === 31) assert.deepEqual(rows.filter(row => row.action === 101).map(row => row.fields[0]), [0, 1]);
+ }
+ const bytes = new Packet(mode).node(name, attrs(...names), [...children]).word(...facts).bytes();
+ for (let end = 0; end < bytes.length; end++) assert.throws(() => run(bytes.subarray(0, end)));
+ assert.throws(() => run(Uint8Array.of(...bytes, 0)));
+ const sizing = bytes.slice(); sizing[3] = 0;
+ assert.equal(new DataView(run(sizing).buffer).getFloat64(8, true), names.length + tail.length);
+ }
+ const slot = new Packet(31).node("stepper", attrs("label", "active"), [{ kind: 6 }]).word(3, 1).actions();
+ assert.deepEqual(slot.map(row => [row.action, row.fields[0]]), [[1, 1], [4, 0], [100, 0], [102, 0], [102, 1], [102, 2], [200, 0]]);
+ assert.deepEqual(actions(new Packet(31).node("stepper", attrs("active"), [{ kind: 6 }]).word(0, 1)), [1, 100, 200]);
+ assert.deepEqual(actions(new Packet(31).node("stepper", attrs("active")).word(0, 0)), [1, 100, 200]);
+ for (const facts of [[1, 0], [0, 1], [2, 1], [0, 2]]) assert.throws(() => run(new Packet(31).node("stepper", attrs("active")).word(...facts).bytes()));
+ assert.throws(() => run(new Packet(32).node("markdown", attrs("source")).bytes()));
+});
+
+test("runtime markdown code and stepper programs retain source order and repeated attributes", () => {
+ assert.deepEqual(actions(new Packet(2).node("markdown", attrs("images", "source", "images"))), [5, 1, 5, 200]);
+ assert.deepEqual(actions(new Packet(3).node("code", attrs("label", "source", "label"))), [15, 1, 15, 200]);
+ assert.deepEqual(actions(new Packet(4).node("stepper", attrs("label", "active", "label"))), [4, 1, 4, 100]);
+});
diff --git a/src/primitives/canvas/ui_markup_compiled.zig b/src/primitives/canvas/ui_markup_compiled.zig
index f94fd819f..ac0c47995 100644
--- a/src/primitives/canvas/ui_markup_compiled.zig
+++ b/src/primitives/canvas/ui_markup_compiled.zig
@@ -1222,43 +1222,54 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
break :blk expression.binding;
};
comptime requireVariant(pathVariant(node, entries, source_path, true), &.{.string}, node, markup.markdown_source_message);
- const source_text = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) {
- .string => |text| text,
- else => runtimeFail([]const u8, ui),
- };
-
+ var source_text: []const u8 = undefined;
var options: Md.Options = .{};
- if (comptime (node.attr("on-link") != null)) {
- options.on_link = comptime markdownLinkConstructor(node, node.attr("on-link").?);
- }
- if (comptime (node.attr("on-details") != null)) {
- options.on_details = comptime markdownDetailsConstructor(node, node.attr("on-details").?);
- }
- if (comptime (node.attr("details-expanded") != null)) {
- options.details_expanded = detailsExpandedItems(node, entries, comptime node.attr("details-expanded").?, ui, model, scope);
- }
- if (comptime (node.attr("images") != null)) {
- options.images = markdownImageItems(node, entries, comptime node.attr("images").?, ui, model, scope);
- }
- if (comptime (node.attr("issue-link-base") != null)) {
- const raw = comptime node.attr("issue-link-base").?;
- comptime {
- const expression = markup.parseAttrExpression(raw) orelse fail(node, markup.markdown_issue_link_base_message);
- if (expression == .equals) fail(node, markup.markdown_issue_link_base_message);
- }
- comptime requireVariant(attrExprVariant(node, entries, "issue-link-base", raw), &.{.string}, node, markup.markdown_issue_link_base_message);
- const base = switch (attrExprValue(node, entries, "issue-link-base", raw, ui, model, scope)) {
+ var request = specialized.Request.init(29);
+ defer request.deinit();
+ request.node(node, true);
+ var program = request.program(owner) catch {
+ return runtimeFail(Ui.Node, ui);
+ };
+ defer program.deinit();
+ while (program.next()) |step| switch (step.action) {
+ 1 => source_text = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) {
.string => |text| text,
else => runtimeFail([]const u8, ui),
- };
- const admission = value_programs.specializedValue(owner, 2, .{ .string = base });
- switch (admission.action) {
- 0 => {},
- 1 => options.issue_link_base = base,
- else => @panic("invalid static issue link publication capability"),
- }
- }
- return Md.view(ui, source_text, options);
+ },
+ 2 => if (comptime (node.attr("on-link") != null)) {
+ options.on_link = comptime markdownLinkConstructor(node, node.attr("on-link").?);
+ } else @panic("unavailable static builder channel"),
+ 3 => if (comptime (node.attr("on-details") != null)) {
+ options.on_details = comptime markdownDetailsConstructor(node, node.attr("on-details").?);
+ } else @panic("unavailable static builder channel"),
+ 4 => if (comptime (node.attr("details-expanded") != null)) {
+ options.details_expanded = detailsExpandedItems(node, entries, comptime node.attr("details-expanded").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 5 => if (comptime (node.attr("images") != null)) {
+ options.images = markdownImageItems(node, entries, comptime node.attr("images").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 6 => if (comptime (node.attr("issue-link-base") != null)) {
+ const raw = comptime node.attr("issue-link-base").?;
+ comptime {
+ const expression = markup.parseAttrExpression(raw) orelse fail(node, markup.markdown_issue_link_base_message);
+ if (expression == .equals) fail(node, markup.markdown_issue_link_base_message);
+ }
+ comptime requireVariant(attrExprVariant(node, entries, "issue-link-base", raw), &.{.string}, node, markup.markdown_issue_link_base_message);
+ const base = switch (attrExprValue(node, entries, "issue-link-base", raw, ui, model, scope)) {
+ .string => |text| text,
+ else => runtimeFail([]const u8, ui),
+ };
+ const admission = value_programs.specializedValue(owner, 2, .{ .string = base });
+ switch (admission.action) {
+ 0 => {},
+ 1 => options.issue_link_base = base,
+ else => @panic("invalid static issue link publication capability"),
+ }
+ } else @panic("unavailable static builder channel"),
+ 200 => return Md.view(ui, source_text, options),
+ else => @panic("invalid static builder capability"),
+ };
+ @panic("static builder program did not publish");
}
fn buildMarkdownReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
@@ -1353,74 +1364,85 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
break :blk expression.binding;
};
comptime requireVariant(pathVariant(node, entries, source_path, true), &.{.string}, node, markup.code_source_message);
- const source = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) {
- .string => |text| text,
- else => runtimeFail([]const u8, ui),
- };
-
+ var source: []const u8 = undefined;
var options: Ui.CodeOptions = .{};
var added_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
var removed_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
- if (comptime (node.attr("language") != null)) {
- const name = comptime blk: {
- const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message);
- if (expression != .literal) fail(node, markup.code_language_message);
- if (!canvas.code.isLanguageName(expression.literal)) fail(node, markup.code_language_message);
- break :blk expression.literal;
- };
- options.language = canvas.code.languageFromName(name);
- }
- if (comptime (node.attr("line-numbers") != null)) {
- options.line_numbers = videoFlagValue(node, entries, comptime node.attr("line-numbers").?, ui, model, scope);
- }
- if (comptime (node.attr("added-lines") != null)) {
- const spec = codeLineSpecAttr(node, entries, comptime node.attr("added-lines").?, ui, model, scope);
- options.added_lines = value_programs.lineNumbers(owner, spec, &added_lines_storage) orelse blk: {
- ui.failed = true;
- break :blk added_lines_storage[0..0];
- };
- }
- if (comptime (node.attr("removed-lines") != null)) {
- const spec = codeLineSpecAttr(node, entries, comptime node.attr("removed-lines").?, ui, model, scope);
- options.removed_lines = value_programs.lineNumbers(owner, spec, &removed_lines_storage) orelse blk: {
- ui.failed = true;
- break :blk removed_lines_storage[0..0];
- };
- }
- if (comptime (node.attr("editable") != null)) {
- options.editable = videoFlagValue(node, entries, comptime node.attr("editable").?, ui, model, scope);
- }
- if (comptime (node.attr("on-input") != null)) {
- const expression = comptime (markup.parseMessageExpression(node.attr("on-input").?) orelse
- fail(node, "invalid message expression: on-* takes a Msg tag (\"add\") or tag with one binding payload (\"toggle:{item.id}\")"));
- options.on_input = comptime (inputConstructor(expression.tag) orelse
- fail(node, "on-input tag must carry a TextInputEvent payload"));
- }
- if (comptime (node.attr("wrap") != null)) {
- options.wrap = videoFlagValue(node, entries, comptime node.attr("wrap").?, ui, model, scope);
- }
- if (comptime (node.attr("width") != null)) {
- options.width = floatAttr(node, entries, comptime node.attr("width").?, ui, model, scope);
- }
- if (comptime (node.attr("height") != null)) {
- options.height = floatAttr(node, entries, comptime node.attr("height").?, ui, model, scope);
- }
- if (comptime (node.attr("min-width") != null)) {
- options.min_width = floatAttr(node, entries, comptime node.attr("min-width").?, ui, model, scope);
- }
- if (comptime (node.attr("grow") != null)) {
- options.grow = floatAttr(node, entries, comptime node.attr("grow").?, ui, model, scope);
- }
- if (comptime (node.attr("label") != null)) {
- options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text");
- }
- if (comptime (node.attr("key") != null)) {
- options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings");
- }
- if (comptime (node.attr("global-key") != null)) {
- options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
- }
- return ui.code(options, source);
+ var request = specialized.Request.init(30);
+ defer request.deinit();
+ request.node(node, true);
+ var program = request.program(owner) catch {
+ return runtimeFail(Ui.Node, ui);
+ };
+ defer program.deinit();
+ while (program.next()) |step| switch (step.action) {
+ 1 => source = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) {
+ .string => |text| text,
+ else => runtimeFail([]const u8, ui),
+ },
+ 2 => if (comptime (node.attr("language") != null)) {
+ const name = comptime blk: {
+ const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message);
+ if (expression != .literal) fail(node, markup.code_language_message);
+ if (!canvas.code.isLanguageName(expression.literal)) fail(node, markup.code_language_message);
+ break :blk expression.literal;
+ };
+ options.language = canvas.code.languageFromName(name);
+ } else @panic("unavailable static builder channel"),
+ 3 => if (comptime (node.attr("line-numbers") != null)) {
+ options.line_numbers = videoFlagValue(node, entries, comptime node.attr("line-numbers").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 4 => if (comptime (node.attr("added-lines") != null)) {
+ const spec = codeLineSpecAttr(node, entries, comptime node.attr("added-lines").?, ui, model, scope);
+ options.added_lines = value_programs.lineNumbers(owner, spec, &added_lines_storage) orelse blk: {
+ ui.failed = true;
+ break :blk added_lines_storage[0..0];
+ };
+ } else @panic("unavailable static builder channel"),
+ 5 => if (comptime (node.attr("removed-lines") != null)) {
+ const spec = codeLineSpecAttr(node, entries, comptime node.attr("removed-lines").?, ui, model, scope);
+ options.removed_lines = value_programs.lineNumbers(owner, spec, &removed_lines_storage) orelse blk: {
+ ui.failed = true;
+ break :blk removed_lines_storage[0..0];
+ };
+ } else @panic("unavailable static builder channel"),
+ 6 => if (comptime (node.attr("editable") != null)) {
+ options.editable = videoFlagValue(node, entries, comptime node.attr("editable").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 7 => if (comptime (node.attr("on-input") != null)) {
+ const expression = comptime (markup.parseMessageExpression(node.attr("on-input").?) orelse
+ fail(node, "invalid message expression: on-* takes a Msg tag (\"add\") or tag with one binding payload (\"toggle:{item.id}\")"));
+ options.on_input = comptime (inputConstructor(expression.tag) orelse
+ fail(node, "on-input tag must carry a TextInputEvent payload"));
+ } else @panic("unavailable static builder channel"),
+ 8 => if (comptime (node.attr("wrap") != null)) {
+ options.wrap = videoFlagValue(node, entries, comptime node.attr("wrap").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 9 => if (comptime (node.attr("width") != null)) {
+ options.width = floatAttr(node, entries, comptime node.attr("width").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 10 => if (comptime (node.attr("height") != null)) {
+ options.height = floatAttr(node, entries, comptime node.attr("height").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 11 => if (comptime (node.attr("min-width") != null)) {
+ options.min_width = floatAttr(node, entries, comptime node.attr("min-width").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 12 => if (comptime (node.attr("grow") != null)) {
+ options.grow = floatAttr(node, entries, comptime node.attr("grow").?, ui, model, scope);
+ } else @panic("unavailable static builder channel"),
+ 13 => if (comptime (node.attr("label") != null)) {
+ options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text");
+ } else @panic("unavailable static builder channel"),
+ 14 => if (comptime (node.attr("key") != null)) {
+ options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings");
+ } else @panic("unavailable static builder channel"),
+ 15 => if (comptime (node.attr("global-key") != null)) {
+ options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
+ } else @panic("unavailable static builder channel"),
+ 200 => return ui.code(options, source),
+ else => @panic("invalid static builder capability"),
+ };
+ @panic("static builder program did not publish");
}
fn buildCodeReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
@@ -1653,47 +1675,72 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
}
}
}
- var options: Ui.StepperOptions = .{};
const active_raw = comptime node.attr("active").?;
comptime requireVariant(exprVariant(node, entries, active_raw), &.{.integer}, node, markup.stepper_active_message);
- options.active = runtime_values.collectionIndex(owner, evalExpr(node, entries, active_raw, ui, model, scope), true) orelse runtimeFail(usize, ui);
- if (comptime (node.attr("key") != null)) {
- options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings");
- }
- if (comptime (node.attr("global-key") != null)) {
- options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
- }
- if (comptime (node.attr("label") != null)) {
- options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text");
- }
const slot_index: ?usize = comptime if (node.children.len == 1 and node.children[0].kind == .slot_block) innermostSlotIndex(entries) else null;
if (comptime (node.children.len == 1 and node.children[0].kind == .slot_block and slot_index == null)) {
comptime fail(node.children[0], markup.slot_outside_template_message);
}
const step_nodes = comptime if (slot_index) |index| entries[index].slot_nodes else node.children;
- const steps = ui.arena.alloc(Ui.StepperStep, step_nodes.len) catch {
- ui.failed = true;
- return ui.el(.row, .{}, .{});
+ var options: Ui.StepperOptions = .{};
+ var steps: []Ui.StepperStep = &.{};
+ var request = specialized.Request.init(31);
+ defer request.deinit();
+ request.node(node, true);
+ request.word(step_nodes.len);
+ request.word(@intFromBool(slot_index != null));
+ var program = request.program(owner) catch {
+ return runtimeFail(Ui.Node, ui);
};
- if (comptime (slot_index != null)) {
- const index = comptime slot_index.?;
- const site_scope = scopePayload(entries, index, scope);
- inline for (0..step_nodes.len) |step_index| {
- const child = comptime step_nodes[step_index];
- if (comptime (child.kind != .element or !std.mem.eql(u8, child.name, "step"))) fail(child, markup.stepper_children_message);
- comptime {
- for (child.attrs) |attribute| {
- if (!std.mem.eql(u8, attribute.name, "kind")) fail(child, markup.step_attr_message);
+ defer program.deinit();
+ while (program.next()) |step| switch (step.action) {
+ 1 => options.active = runtime_values.collectionIndex(owner, evalExpr(node, entries, active_raw, ui, model, scope), true) orelse runtimeFail(usize, ui),
+ 2 => if (comptime (node.attr("key") != null)) {
+ options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings");
+ } else @panic("unavailable static builder channel"),
+ 3 => if (comptime (node.attr("global-key") != null)) {
+ options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
+ } else @panic("unavailable static builder channel"),
+ 4 => if (comptime (node.attr("label") != null)) {
+ options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text");
+ } else @panic("unavailable static builder channel"),
+ 100 => steps = ui.arena.alloc(Ui.StepperStep, step_nodes.len) catch {
+ ui.failed = true;
+ return ui.el(.row, .{}, .{});
+ },
+ 101 => if (comptime (slot_index == null)) {
+ var matched = false;
+ inline for (0..step_nodes.len) |index| {
+ if (step.fields[0] == index) {
+ matched = true;
+ steps[index] = .{ .label = interpolatedText(comptime step_nodes[index], entries, ui, model, scope) };
}
}
- steps[step_index] = .{ .label = interpolatedText(child, comptime entries[index].slot_entries, ui, model, site_scope) };
- }
- } else {
- inline for (0..step_nodes.len) |index| {
- steps[index] = .{ .label = interpolatedText(comptime step_nodes[index], entries, ui, model, scope) };
- }
- }
- return ui.stepper(options, steps);
+ if (!matched) @panic("invalid static step index");
+ } else @panic("unavailable static step scope"),
+ 102 => if (comptime (slot_index != null)) {
+ const index = comptime slot_index.?;
+ const site_scope = scopePayload(entries, index, scope);
+ var matched = false;
+ inline for (0..step_nodes.len) |step_index| {
+ const child = comptime step_nodes[step_index];
+ if (comptime (child.kind != .element or !std.mem.eql(u8, child.name, "step"))) fail(child, markup.stepper_children_message);
+ comptime {
+ for (child.attrs) |attribute| {
+ if (!std.mem.eql(u8, attribute.name, "kind")) fail(child, markup.step_attr_message);
+ }
+ }
+ if (step.fields[0] == step_index) {
+ matched = true;
+ steps[step_index] = .{ .label = interpolatedText(child, comptime entries[index].slot_entries, ui, model, site_scope) };
+ }
+ }
+ if (!matched) @panic("invalid static step index");
+ } else @panic("unavailable static step scope"),
+ 200 => return ui.stepper(options, steps),
+ else => @panic("invalid static builder capability"),
+ };
+ @panic("static builder program did not publish");
}
fn buildStepperReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
diff --git a/tests/ts-core/static_markup_content_e2e_tests.zig b/tests/ts-core/static_markup_content_e2e_tests.zig
new file mode 100644
index 000000000..76915bc27
--- /dev/null
+++ b/tests/ts-core/static_markup_content_e2e_tests.zig
@@ -0,0 +1,314 @@
+const std = @import("std");
+const sdk = @import("native_sdk");
+const core = @import("surface_fixture_core");
+const c = sdk.canvas;
+fn exact(a: anytype, b: @TypeOf(a)) anyerror!void {
+ const T = @TypeOf(a);
+ switch (@typeInfo(T)) {
+ .@"struct" => |info| inline for (info.fields) |field| try exact(@field(a, field.name), @field(b, field.name)),
+ .optional => {
+ try std.testing.expectEqual(a != null, b != null);
+ if (a) |value| try exact(value, b.?);
+ },
+ .pointer => |info| if (info.size == .slice) {
+ try std.testing.expectEqual(a.len, b.len);
+ for (a, b) |l, r| try exact(l, r);
+ } else if (info.size == .one and (@typeInfo(info.child) == .@"struct" or @typeInfo(info.child) == .@"union" or info.child == bool)) try exact(a.*, b.*) else try std.testing.expectEqual(a, b),
+ .array => for (a, b) |l, r| try exact(l, r),
+ .@"union" => {
+ try std.testing.expectEqual(std.meta.activeTag(a), std.meta.activeTag(b));
+ switch (a) {
+ inline else => |value, tag| try exact(value, @field(b, @tagName(tag))),
+ }
+ },
+ else => try @import("surface_layout_e2e_tests.zig").expectComplete(a, b),
+ }
+}
+/// Counts text slices that alias the model's storage, so borrowed and owned
+/// buffers stay distinguishable even when their bytes are equal.
+fn borrowed(value: anytype, base: []const u8) usize {
+ const T = @TypeOf(value);
+ return switch (@typeInfo(T)) {
+ .@"struct" => |info| blk: {
+ var total: usize = 0;
+ inline for (info.fields) |field| total += borrowed(@field(value, field.name), base);
+ break :blk total;
+ },
+ .optional => if (value) |inner| borrowed(inner, base) else 0,
+ .@"union" => switch (value) {
+ inline else => |inner| borrowed(inner, base),
+ },
+ .pointer => |info| if (info.size != .slice) 0 else if (info.child == u8) @intFromBool(value.len > 0 and @intFromPtr(value.ptr) >= @intFromPtr(base.ptr) and @intFromPtr(value.ptr) < @intFromPtr(base.ptr) + base.len) else blk: {
+ var total: usize = 0;
+ for (value) |item| total += borrowed(item, base);
+ break :blk total;
+ },
+ .array => blk: {
+ var total: usize = 0;
+ for (value) |item| total += borrowed(item, base);
+ break :blk total;
+ },
+ else => 0,
+ };
+}
+
+var calls: [4096]u64 = undefined;
+var call_count: usize = 0;
+var specialized: [256]usize = @splat(0);
+fn observed(input: []const u8, output: []u8) usize {
+ if (input[0] == 112) specialized[input[2]] += 1;
+ const count = core.nativeWindowPolicy(input, output);
+ core.rt.frameReset();
+ return count;
+}
+fn event(value: u64) void {
+ calls[call_count] = value;
+ call_count += 1;
+}
+const storage = "Borrowed Café|# Document\nIssue #12 and [docs](https://example.invalid/docs)\nMore
Body \nLess
Tail |const x = 1;\nreturn x;\n// Done|https://example.invalid/issues/|2-3,1|4|Outer|Inner|\x00\xff";
+fn part(comptime index: usize) []const u8 {
+ return comptime blk: {
+ @setEvalBranchQuota(10_000);
+ var parts = std.mem.splitScalar(u8, storage, '|');
+ for (0..index) |_| _ = parts.next();
+ break :blk parts.next().?;
+ };
+}
+const Image = struct { source: []const u8, image: u64, width: f32, height: f32 };
+const Row = struct { label: []const u8 };
+const Model = struct {
+ raw: []const u8 = part(0),
+ document: []const u8 = part(1),
+ program: []const u8 = part(2),
+ prefix: []const u8 = part(3),
+ lines: []const u8 = part(4),
+ removed: []const u8 = part(5),
+ number: f64 = 12.5,
+ identity: i64 = 9007199254740993,
+ step: i64 = 1,
+ truth: bool = true,
+ flags: []const bool = &.{ false, true },
+ pictures: []const Image = &.{.{ .source = part(1)[0..10], .image = 4294967297, .width = 10.25, .height = 20.5 }},
+ rows: []const Row = &.{ .{ .label = part(6) }, .{ .label = part(7) } },
+ pub fn label(self: *const Model) []const u8 {
+ event(1);
+ return self.raw;
+ }
+ pub fn width(self: *const Model) f64 {
+ event(2);
+ return self.number;
+ }
+ pub fn height(self: *const Model) f64 {
+ event(3);
+ return self.number;
+ }
+ pub fn minimum(self: *const Model) f64 {
+ event(4);
+ return self.number;
+ }
+ pub fn grow(self: *const Model) f64 {
+ event(5);
+ return self.number;
+ }
+ pub fn key(self: *const Model) i64 {
+ event(6);
+ return self.identity;
+ }
+ pub fn global(self: *const Model) i64 {
+ event(7);
+ return self.identity;
+ }
+ pub fn body(self: *const Model) []const u8 {
+ event(8);
+ return self.document;
+ }
+ pub fn expanded(self: *const Model) []const bool {
+ event(9);
+ return self.flags;
+ }
+ pub fn images(self: *const Model) []const Image {
+ event(10);
+ return self.pictures;
+ }
+ pub fn base(self: *const Model) []const u8 {
+ event(11);
+ return self.prefix;
+ }
+ pub fn code(self: *const Model) []const u8 {
+ event(12);
+ return self.program;
+ }
+ pub fn numbers(self: *const Model) bool {
+ event(13);
+ return self.truth;
+ }
+ pub fn added(self: *const Model) []const u8 {
+ event(14);
+ return self.lines;
+ }
+ pub fn deleted(self: *const Model) []const u8 {
+ event(15);
+ return self.removed;
+ }
+ pub fn editable(self: *const Model) bool {
+ event(16);
+ return self.truth;
+ }
+ pub fn wrap(self: *const Model) bool {
+ event(17);
+ return !self.truth;
+ }
+ pub fn active(self: *const Model) i64 {
+ event(18);
+ return self.step;
+ }
+ pub fn title(self: *const Model) []const u8 {
+ event(19);
+ return self.raw;
+ }
+ pub fn poison(self: *const Model) []const u8 {
+ event(99);
+ return self.raw;
+ }
+};
+const Msg = union(enum) { tick, link: []const u8, details: usize, edited: c.TextInputEvent };
+const Ui = c.Ui(Msg);
+const unlimited = std.math.maxInt(usize);
+const Outcome = struct { allocations: usize, resizes: usize, calls: usize };
+fn compare(comptime source: []const u8, model: Model, failure: usize, resize: usize) !Outcome {
+ const View = c.CompiledMarkupView(Model, Msg, source);
+ var aa = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer aa.deinit();
+ var ab = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer ab.deinit();
+ var fa = std.testing.FailingAllocator.init(aa.allocator(), .{ .fail_index = failure, .resize_fail_index = resize });
+ var fb = std.testing.FailingAllocator.init(ab.allocator(), .{ .fail_index = failure, .resize_fail_index = resize });
+ var left = Ui.init(fa.allocator());
+ var right = Ui.init(fb.allocator());
+ right.expression_policy = observed;
+ call_count = 0;
+ const a = View.build(&left, &model);
+ const before = calls;
+ const count = call_count;
+ call_count = 0;
+ const b = View.build(&right, &model);
+ errdefer std.debug.print("static content builders source={s} failure={d} resize={d}\n", .{ source, failure, resize });
+ try exact(before[0..count], calls[0..call_count]);
+ try exact(a, b);
+ try exact(borrowed(a, storage), borrowed(b, storage));
+ const at = left.finalize(a);
+ const bt = right.finalize(b);
+ if (at) |tree| {
+ const tree_b = try bt;
+ try exact(tree, tree_b);
+ try exact(borrowed(tree, storage), borrowed(tree_b, storage));
+ } else |err| try std.testing.expectError(err, bt);
+ try exact(left.failed, right.failed);
+ try exact(left.virtualWindows(), right.virtualWindows());
+ try exact(fa.alloc_index, fb.alloc_index);
+ try exact(fa.resize_index, fb.resize_index);
+ return .{ .allocations = fa.alloc_index, .resizes = fa.resize_index, .calls = count };
+}
+fn check(comptime source: []const u8, model: Model, faults: bool) !void {
+ const result = try compare(source, model, unlimited, unlimited);
+ if (faults) {
+ for (0..result.allocations + 1) |index| _ = try compare(source, model, index, unlimited);
+ for (0..result.resizes + 1) |index| _ = try compare(source, model, unlimited, index);
+ }
+}
+
+test "static markup content builders bind markdown source before fixed first-occurrence channels" {
+ _ = core.initialModel();
+ specialized = @splat(0);
+ const source = "";
+ _ = try compare(source, .{}, unlimited, unlimited);
+ try exact(@as([]const u64, &.{ 8, 9, 10, 11 }), calls[0..call_count]);
+ try check(source, .{}, true);
+ try check(source, .{ .prefix = "", .flags = &.{}, .pictures = &.{} }, true);
+ try check(source, .{ .document = "", .prefix = part(8) }, true);
+ try std.testing.expect(specialized[29] > 0);
+ try check("", .{}, true);
+ try check("{title}", .{}, true);
+}
+
+test "static markup content builders keep markdown template captures and later callbacks" {
+ _ = core.initialModel();
+ const source = "";
+ for ([_][]const u8{ "", " ", "https://example.invalid/issues/", "\x00\xff" }) |prefix| try check(source, .{ .prefix = prefix }, true);
+ try check(source, .{ .rows = &.{} }, true);
+}
+
+test "static markup content builders fix code channels and keep stack line storage through publication" {
+ _ = core.initialModel();
+ specialized = @splat(0);
+ const source = "";
+ _ = try compare(source, .{}, unlimited, unlimited);
+ try exact(@as([]const u64, &.{ 12, 13, 14, 15, 16, 17, 2, 3, 4, 5, 1, 6, 7 }), calls[0..call_count]);
+ try check(source, .{}, true);
+ try check(source, .{ .truth = false, .raw = "", .identity = std.math.minInt(i64) }, true);
+ try check(source, .{ .program = "", .lines = "", .removed = "" }, true);
+ try std.testing.expect(specialized[30] > 0);
+ try check("", .{}, true);
+ try check("", .{}, true);
+}
+
+test "static markup content builders continue code callbacks after invalid line metadata" {
+ _ = core.initialModel();
+ const source = "{title}";
+ for ([_][]const u8{ "", "1", "128", "1-128", "0", "129", "2-1", "1,,2", "1,2,x", "18446744073709551616", "\xff" }) |lines| {
+ try check(source, .{ .lines = lines }, true);
+ try check(source, .{ .removed = lines }, false);
+ }
+ try check(source, .{ .lines = "2", .removed = "2" }, true);
+ const template = "{title}";
+ try check(template, .{}, true);
+ try check(template, .{ .removed = "x" }, true);
+}
+
+test "static markup content builders evaluate stepper active and channels before allocating steps" {
+ _ = core.initialModel();
+ specialized = @splat(0);
+ const source = "{title}Step {title}Fixed";
+ _ = try compare(source, .{}, unlimited, unlimited);
+ try exact(@as([]const u64, &.{ 18, 6, 7, 1, 19, 19 }), calls[0..call_count]);
+ try check(source, .{}, true);
+ for ([_]i64{ std.math.minInt(i64), -9007199254740993, -1, 0, 2, 3, 9007199254740993, std.math.maxInt(i64) }) |step| try check(source, .{ .step = step }, true);
+ try check(source, .{ .raw = "", .identity = std.math.maxInt(i64) }, true);
+ try std.testing.expect(specialized[31] > 0);
+ try check("", .{}, true);
+ try check("Only{title}", .{}, true);
+}
+
+test "static markup content builders interpolate slot steps in their lexical site scope" {
+ _ = core.initialModel();
+ specialized = @splat(0);
+ const source = "{row.label}Then {row.label} {title}{title}";
+ _ = try compare(source, .{}, unlimited, unlimited);
+ try exact(@as([]const u64, &.{ 19, 18, 19, 19, 18, 19, 18, 19 }), calls[0..call_count]);
+ try check(source, .{}, true);
+ try check(source, .{ .rows = &.{} }, true);
+ try check(source, .{ .rows = &.{.{ .label = part(8) }}, .raw = "", .step = -1 }, true);
+ try std.testing.expect(specialized[31] > 0);
+}
+
+test "static markup content builders preserve borrowed sources labels and owned interpolation" {
+ _ = core.initialModel();
+ const source = "{title}Step {title}";
+ const View = c.CompiledMarkupView(Model, Msg, source);
+ var aa = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer aa.deinit();
+ var ab = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer ab.deinit();
+ var left = Ui.init(aa.allocator());
+ var right = Ui.init(ab.allocator());
+ right.expression_policy = observed;
+ const model: Model = .{};
+ const a = View.build(&left, &model);
+ const b = View.build(&right, &model);
+ try exact(a, b);
+ try std.testing.expect(borrowed(a, storage) > 0);
+ try exact(borrowed(a, storage), borrowed(b, storage));
+ try std.testing.expect(a.nodes[2].widget.semantics.label.ptr == model.raw.ptr);
+ try std.testing.expect(b.nodes[2].widget.semantics.label.ptr == model.raw.ptr);
+ try check(source, .{}, true);
+}
diff --git a/tests/ts-core/surface_layout_e2e_tests.zig b/tests/ts-core/surface_layout_e2e_tests.zig
index 2d4edd11d..68fe3a88c 100644
--- a/tests/ts-core/surface_layout_e2e_tests.zig
+++ b/tests/ts-core/surface_layout_e2e_tests.zig
@@ -1015,4 +1015,5 @@ test {
_ = @import("static_markup_specialized_e2e_tests.zig");
_ = @import("static_markup_typography_e2e_tests.zig");
_ = @import("static_markup_builders_e2e_tests.zig");
+ _ = @import("static_markup_content_e2e_tests.zig");
}