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
87 changes: 55 additions & 32 deletions src/primitives/canvas/ui_markup_compiled.zig

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/primitives/canvas/ui_markup_compiled_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,32 @@ const selection_label_content_markup =
;
const SelectionLabelContentCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, selection_label_content_markup);

const digit_text_attribute_markup =
\\<column padding="8">
\\ <text-field placeholder="123" label="456" />
\\</column>
;
const DigitTextAttributeCompiled = canvas.CompiledMarkupView(fixture.Model, fixture.Msg, digit_text_attribute_markup);

test "compiled digit-only text attributes stay text while numeric attributes stay numeric" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = fixture.Model{};

var interpreter = try InboxInterpreter.init(arena, digit_text_attribute_markup);
var interpreter_ui = InboxUi.init(arena);
const interpreted = try interpreter_ui.finalize(try interpreter.build(&interpreter_ui, &model));
var compiled_ui = InboxUi.init(arena);
const compiled = try compiled_ui.finalize(DigitTextAttributeCompiled.build(&compiled_ui, &model));

try expectSameTree(fixture.Msg, interpreted, compiled);
const field = fixture.findByKind(compiled.root, .text_field).?;
try testing.expectEqualStrings("123", field.placeholder);
try testing.expectEqualStrings("456", field.semantics.label);
try testing.expectEqual(@as(f32, 8), compiled.root.layout.padding.top);
}

test "checkbox and radio element content builds identically in both markup engines" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down
17 changes: 14 additions & 3 deletions src/primitives/canvas/ui_markup_contract.zig
Original file line number Diff line number Diff line change
Expand Up @@ -774,12 +774,15 @@ const Checker = struct {
/// against the contract and the whole expression run through the
/// shared type checker with those kinds — `{count > 'a'}` fails here
/// with the evaluator's teaching message and the model field's type.
fn attrKind(self: *Checker, node: markup.MarkupNode, attribute: markup.MarkupAttr, raw: []const u8) CheckErr!?ValueKind {
fn expressionKind(self: *Checker, node: markup.MarkupNode, attribute: markup.MarkupAttr, raw: []const u8, attribute_aware: bool) CheckErr!?ValueKind {
const expression = markup.parseAttrExpression(raw) orelse {
return self.failAttr(node, attribute, markup.invalid_expression_message);
};
return switch (expression) {
.literal => |text| expr.kindOf(reflect.literalValue(text)),
.literal => |text| expr.kindOf(if (attribute_aware)
reflect.literalValueForAttribute(text, attribute.name)
else
reflect.literalValue(text)),
.binding => |path| (try self.resolveBinding(node, path, true)).kind,
.equals => |sides| blk: {
// Arena-computed bindings are excluded from equality on
Expand All @@ -793,6 +796,14 @@ const Checker = struct {
};
}

fn attrKind(self: *Checker, node: markup.MarkupNode, attribute: markup.MarkupAttr, raw: []const u8) CheckErr!?ValueKind {
return self.expressionKind(node, attribute, raw, true);
}

fn unclassifiedKind(self: *Checker, node: markup.MarkupNode, attribute: markup.MarkupAttr, raw: []const u8) CheckErr!?ValueKind {
return self.expressionKind(node, attribute, raw, false);
}

fn exprTreeKind(self: *Checker, node: markup.MarkupNode, inner: []const u8) CheckErr!?ValueKind {
var tree: expr.ExprTree = .{};
var diagnostic: expr.Diagnostic = .{};
Expand Down Expand Up @@ -1120,7 +1131,7 @@ const Checker = struct {
}
}
}
return .{ .value = try self.attrKind(node, attribute, attribute.value) };
return .{ .value = try self.unclassifiedKind(node, attribute, attribute.value) };
}

fn checkSlot(self: *Checker, node: markup.MarkupNode) CheckErr!void {
Expand Down
9 changes: 9 additions & 0 deletions src/primitives/canvas/ui_markup_contract_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ const fixtures = [_]Fixture{
,
.expect = null,
},
.{
.name = "digit-only text attributes remain text",
.source =
\\<column padding="8">
\\ <text-field placeholder="123" label="456" />
\\</column>
,
.expect = null,
},
.{
.name = "a missing model field rejects",
.source =
Expand Down
13 changes: 13 additions & 0 deletions src/primitives/canvas/ui_markup_reflect.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

const std = @import("std");
const expr = @import("ui_markup_expr.zig");
const schema = @import("ui_schema.zig");

/// Comptime walks over an app's Model and Msg scale with the type's
/// field/decl count, and the default 1000-backwards-branch quota dies at
Expand Down Expand Up @@ -449,3 +450,15 @@ pub fn literalValue(text: []const u8) expr.Value {
if (std.fmt.parseFloat(f32, text)) |float| return .{ .float = float } else |_| {}
return .{ .string = text };
}

/// A bare attribute literal keeps the schema's declared text shape. Text
/// attributes are the one exception to the general literal inference above:
/// `placeholder="123"` is text, while numeric, whole-number, flag, option,
/// and key attributes retain their existing inference and truthiness rules.
/// Expression literals and template defaults intentionally continue to use
/// `literalValue` directly.
pub fn literalValueForAttribute(text: []const u8, attribute_name: []const u8) expr.Value {
const info = schema.attrByName(attribute_name) orelse return literalValue(text);
if (info.class == .text) return .{ .string = text };
return literalValue(text);
}
18 changes: 15 additions & 3 deletions src/primitives/canvas/ui_markup_view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1741,7 +1741,7 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
}
}
}
return .{ .value = try self.evalAttrExpression(scope, node, attribute) };
return .{ .value = try self.evalUnclassifiedExpression(scope, node, attribute) };
}

fn failPayload(self: *Self, node: markup.MarkupNode, message: []const u8) BuildError {
Expand Down Expand Up @@ -2443,9 +2443,12 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
/// classified (and its expression tree parsed) once at document
/// level, not per frame per use — the typed-document pass's whole
/// point for the interpreter.
fn evalAttrExpression(self: *Self, scope: *Scope, node: markup.MarkupNode, attribute: markup.MarkupAttr) BuildError!Value {
fn evalAttributeExpression(self: *Self, scope: *Scope, node: markup.MarkupNode, attribute: markup.MarkupAttr, attribute_aware: bool) BuildError!Value {
return switch (markup.attrTyped(attribute)) {
.literal => |text| literalValue(text),
.literal => |text| if (attribute_aware)
literalValueForAttribute(text, attribute.name)
else
literalValue(text),
.binding => |path| try self.evalBinding(scope, node, path, true),
// Arena-computed bindings are excluded from equality on
// purpose: comparing freshly formatted strings is a smell —
Expand All @@ -2459,6 +2462,14 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
};
}

fn evalAttrExpression(self: *Self, scope: *Scope, node: markup.MarkupNode, attribute: markup.MarkupAttr) BuildError!Value {
return self.evalAttributeExpression(scope, node, attribute, true);
}

fn evalUnclassifiedExpression(self: *Self, scope: *Scope, node: markup.MarkupNode, attribute: markup.MarkupAttr) BuildError!Value {
return self.evalAttributeExpression(scope, node, attribute, false);
}

/// A typed expression: use the pre-parsed tree when the pass
/// stamped one; a missing tree means the text does not parse (or
/// the document was never canonicalized), and the re-parse
Expand Down Expand Up @@ -2893,6 +2904,7 @@ pub fn valueOf(comptime T: type, value: T) ?Value {
}

pub const literalValue = reflect.literalValue;
pub const literalValueForAttribute = reflect.literalValueForAttribute;

/// Display-text formatting for interpolation and `++` concatenation:
/// defined once in the expression core so both engines (and the evaluator
Expand Down
18 changes: 18 additions & 0 deletions src/primitives/canvas/ui_markup_view_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ test "markup view builds the same tree as the hand-written view" {
);
}

test "digit-only text attributes stay text while numeric attributes stay numeric" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();

var view = try InboxMarkup.init(
arena,
"<column padding=\"8\">\n <text-field placeholder=\"123\" label=\"456\" />\n</column>",
);
var ui = InboxUi.init(arena);
const tree = try ui.finalize(try view.build(&ui, &Model{}));
const field = findByKind(tree.root, .text_field).?;

try testing.expectEqualStrings("123", field.placeholder);
try testing.expectEqualStrings("456", field.semantics.label);
try testing.expectEqual(@as(f32, 8), tree.root.layout.padding.top);
}

test "markup keyed rows keep ids across model changes and filters dispatch" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
Expand Down
Loading