Skip to content
Open
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
24 changes: 24 additions & 0 deletions docs/content/docs/components/textarea.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ Editing shortcuts follow [input](/docs/components/input#editing-keys). On macOS,
<textarea width="320" height="96" placeholder="Write a release note" text="{draft}" on-input="draft_edited" />
```

## Content-sized height

Set `auto-height-max` to a positive number of points to grow a textarea with its
content. `auto-height-min` sets its minimum height. The renderer measures explicit
newlines and soft-wrapped lines using the same font metrics and available width
as painting. Deleting text shrinks the editor again. At the maximum height,
existing caret scrolling keeps the insertion point visible.

```html
<textarea label="Message" auto-height-min="44" auto-height-max="240" text="{draft}" on-input="draft_edited" />
```

Both bounds accept numeric model bindings. They must be finite and nonnegative;
the effective maximum is at least the minimum. A zero or omitted maximum keeps
the existing fixed-size behavior. Do not combine either bound with `height`, or
put the bounds on another element. In a growing chat layout, reserve room for
the composer and its actions; these bounds do not limit the surrounding window.

To reproduce the growth behavior, enter three lines, paste enough text to wrap
past the maximum, then delete back to one line. Repeat at a narrower width: the
same text should occupy more lines, without changing its contents or selection.

## Programmatic construction (Zig)

In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
Expand All @@ -44,5 +66,7 @@ ui.el(.textarea, .{
"submit-on-enter",
"on-input",
"on-submit",
"auto-height-min",
"auto-height-max",
]}
/>
2 changes: 1 addition & 1 deletion packages/core/src/runtime_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1641,7 +1641,7 @@ function nscvWrappedLayout(request: Uint8Array): Uint8Array {
const v = (at: number): number => wire.getFloat32(at, true);
let mode = 0;
if ((kind === 26 || kind === 44) && (flags & 1) !== 0) mode = 1;
else if (kind === 2 || kind === 7 || kind === 4 || kind === 5 || kind === 24 || kind === 25) mode = (flags & 2) !== 0 ? 0 : 2;
else if (kind === 2 || kind === 7 || kind === 4 || kind === 5 || kind === 24 || kind === 25 || kind === 60) mode = (flags & 2) !== 0 ? 0 : 2;
else if (kind === 0 || kind === 22 || kind === 18 || kind === 15 || kind === 16 || kind === 23) mode = 3;
else if (kind === 17) mode = 4;
else if (kind === 14) mode = 5;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/view_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type NscContextMenuItem = { label: string; labelBytes?: readonly number[]; press
type NscChartSeries = { kind: string; values: readonly number[]; color: string; fill: boolean; label: readonly number[] };

type NscViewNode = {
end: number; kind: string; text: string; textBytes?: readonly number[]; labelBytes?: readonly number[]; placeholderBytes?: readonly number[]; placeholder?: string; command?: string; wrap?: boolean; submitOnEnter?: boolean;
end: number; kind: string; text: string; textBytes?: readonly number[]; labelBytes?: readonly number[]; placeholderBytes?: readonly number[]; placeholder?: string; command?: string; wrap?: boolean; submitOnEnter?: boolean; autoHeightMin?: number; autoHeightMax?: number;
key?: string; keyBytes?: readonly number[]; keyInt?: number; keySlot?: number; globalKey?: string; globalKeyBytes?: readonly number[]; globalKeyInt?: number;
columns?: number; virtualized?: boolean; virtualItemExtent?: number; virtualWindow?: number;
gap?: number; padding?: number; grow?: number; width?: number; height?: number; minWidth?: number; maxWidth?: number;
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/view_frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,11 @@ function compileViewFunction(source: string, contract: ViewContract, options: Vi
if (!["start", "center", "end"].includes(value)) fail(node, "unsupported text-alignment");
props.push(v.prop("textAlignment", v.str(value)));
}
else if (name === "auto-height-min" || name === "auto-height-max") {
if (node.name !== "textarea" || node.attrs.has("height")) fail(node, "auto-height bounds require textarea without a definite height");
if (!value.startsWith("{") && (!Number.isFinite(Number(value)) || Number(value) < 0)) fail(node, "auto-height bounds must be finite nonnegative points");
props.push(v.prop(name === "auto-height-min" ? "autoHeightMin" : "autoHeightMax", bound(value, "number", node, scope)));
}
else if (name === "min-width" || name === "max-width") props.push(v.prop(name === "min-width" ? "minWidth" : "maxWidth", bound(value, "number", node, scope)));
else if (["resize-duration", "resize-easing", "resize-origin"].includes(name)) {
if (node.name !== "split") fail(node, `${name} requires split`);
Expand Down
12 changes: 12 additions & 0 deletions packages/core/test/view_frontend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2411,3 +2411,15 @@ test("compiled charts reject incorrect channels child placement and untyped arra
]) assert.throws(() => compileView(markup, chartContract));
for (const grid of ["-1", "256", "0.5"]) assert.throws(() => evaluateChart(`<chart grid-lines="${grid}"><series values="{samples}"/></chart>`, { samples: [], labels: [], caption: new Uint8Array(0) }));
});

test("textarea autoheight emits numeric bounds and rejects invalid opt-in declarations", () => {
const compiled = compileView('<textarea auto-height-min="44" auto-height-max="{count}"/>', contract);
assert.match(compiled, /autoHeightMin: 44/);
assert.match(compiled, /autoHeightMax:/);
for (const markup of [
'<textarea auto-height-min="-1" auto-height-max="200"/>',
'<textarea auto-height-max="Infinity"/>',
'<textarea height="44" auto-height-max="200"/>',
'<text auto-height-max="200">wrong kind</text>',
]) assert.throws(() => compileView(markup, contract));
});
8 changes: 8 additions & 0 deletions skill-data/native-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ Render channel (Zig-only, no markup attributes): `ElementOptions.opacity` and `E

Numbers are plain (`gap="12"`), booleans are `true`/`false` or a binding.

For a content-sized `textarea`, set `auto-height-min="44"` and
`auto-height-max="240"` (points, or numeric model bindings). The renderer measures
wrapped text at the actual editor width, grows and shrinks within these bounds,
then scrolls the caret into view at the cap. Bounds must be finite and nonnegative;
the effective cap is never below the minimum. A zero or omitted maximum preserves
fixed-size behavior. These attributes are textarea-only and cannot accompany a
definite `height`. An `input-group` includes the measured editor and its actions.

When children's minimum sizes exceed their container, debug builds log a `zero_canvas_layout` diagnostic naming the container, axis, and overflow in pixels — flex overflow is never silent. In Zig views, `.gap` on a stacking kind (`ui.panel(.{ .gap = 8 }, ...)`) logs a `zero_canvas_ui` warning in debug builds with the same lesson — it never fails the build.

### Chips: exclusive selection with `selected=`
Expand Down
20 changes: 20 additions & 0 deletions src/app_runner/ts_compiled_view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ const Record = struct {
command: []const u8 = "",
wrap: ?bool = null,
submitOnEnter: bool = false,
autoHeightMin: f32 = 0,
autoHeightMax: f32 = 0,
key: ?[]const u8 = null,
keyBytes: ?ByteText = null,
keyInt: ?i64 = null,
Expand Down Expand Up @@ -405,6 +407,8 @@ fn nodeRecord(ui: *Ui, records: []const Record, index: usize, parent_end: usize,
if ((value.kind == .code or value.kind == .input_group or value.kind == .input_group_actions) and
(value.paddingTop != null or value.paddingBottom != null or value.paddingLeft != null or value.paddingRight != null)) return error.InvalidView;
for ([_]f32{ value.width, value.height, value.minWidth, value.maxWidth }) |extent| if (!std.math.isFinite(extent) or extent < 0) return error.InvalidView;
for ([_]f32{ value.autoHeightMin, value.autoHeightMax }) |extent| if (!std.math.isFinite(extent) or extent < 0) return error.InvalidView;
if ((value.autoHeightMin != 0 or value.autoHeightMax != 0) and (value.kind != .textarea or value.height != 0)) return error.InvalidView;
if (value.kind == .resizable and value.gap != 0) return error.InvalidView;
if (value.resize != null and value.kind != .split) return error.InvalidView;
if (!std.math.isFinite(value.resizeOrigin) or (value.resizeOrigin != -1 and (value.resizeOrigin < 0 or value.resizeOrigin > 1))) return error.InvalidView;
Expand Down Expand Up @@ -593,6 +597,8 @@ fn nodeRecord(ui: *Ui, records: []const Record, index: usize, parent_end: usize,
.overflow = value.overflow,
.text_alignment = value.textAlignment,
.submit_on_enter = value.submitOnEnter,
.auto_height_min = value.autoHeightMin,
.auto_height_max = value.autoHeightMax,
.autofocus = value.autofocus,
.columns = value.columns,
.virtualized = value.virtualized,
Expand Down Expand Up @@ -1700,3 +1706,17 @@ test "compiled app chart rejects misplaced metadata unbounded samples and invali
const bytes = try std.fmt.allocPrint(arena.allocator(), "{{\"format\":2,\"nodes\":[{{\"end\":1,\"kind\":\"chart\",\"text\":\"\",\"chartSeries\":[{{\"kind\":\"line\",\"values\":[{s}],\"color\":\"accent\",\"fill\":false,\"label\":[]}}],\"chartXLabels\":[]}}]}}", .{values});
try std.testing.expectError(error.InvalidView, decode(&ui, bytes));
}

test "compiled grouped input autoheight validates opt-in bounds" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var ui = Ui.init(arena.allocator());
const retained = try decode(&ui, "{\"format\":2,\"nodes\":[{\"end\":1,\"kind\":\"textarea\",\"text\":\"one\",\"autoHeightMin\":44,\"autoHeightMax\":240}]}");
try std.testing.expectEqual(@as(f32, 44), retained.widget.auto_height_min);
try std.testing.expectEqual(@as(f32, 240), retained.widget.auto_height_max);
for ([_][]const u8{ "\"autoHeightMin\":-1", "\"autoHeightMax\":-1", "\"autoHeightMax\":200,\"height\":44" }) |fields| {
const bytes = try std.fmt.allocPrint(arena.allocator(), "{{\"format\":2,\"nodes\":[{{\"end\":1,\"kind\":\"textarea\",\"text\":\"\",{s}}}]}}", .{fields});
try std.testing.expectError(error.InvalidView, decode(&ui, bytes));
}
try std.testing.expectError(error.InvalidView, decode(&ui, "{\"format\":2,\"nodes\":[{\"end\":1,\"kind\":\"text\",\"text\":\"\",\"autoHeightMax\":200}]}"));
}
6 changes: 6 additions & 0 deletions src/primitives/canvas/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,10 @@ pub fn Ui(comptime Msg: type) type {
/// default textarea contract (Enter inserts; primary+Enter
/// submits). Meaningless on every other element.
submit_on_enter: bool = false,
/// Textareas only: measure wrapped content using the renderer's font
/// provider, then clamp to these point bounds. Zero max opts out.
auto_height_min: f32 = 0,
auto_height_max: f32 = 0,
variant: canvas.WidgetVariant = .default,
size: canvas.WidgetSize = .default,
/// Resolve backdrop blur against the live design-token register.
Expand Down Expand Up @@ -3927,6 +3931,8 @@ pub fn Ui(comptime Msg: type) type {
.text_overflow = options.overflow,
.autofocus = options.autofocus,
.submit_on_enter = options.submit_on_enter,
.auto_height_min = options.auto_height_min,
.auto_height_max = options.auto_height_max,
.image_id = options.image,
.image_src = options.image_src,
.value = options.value,
Expand Down
19 changes: 19 additions & 0 deletions src/primitives/canvas/ui_markup.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,9 @@ pub const autofocus_element_message = "autofocus is only supported on focusable

pub const submit_on_enter_element_message = "submit-on-enter is only supported on textarea - it makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; single-line fields already submit on Enter, and other elements have no multiline Enter policy";

pub const auto_height_element_message = "auto-height bounds require textarea without a definite height";
pub const auto_height_value_message = "auto-height bounds must be finite nonnegative points";

pub const non_hit_target_handler_message = "on-change/on-submit/on-input never fire here: this element has no control or text behavior - put them on a control (input, checkbox, slider) inside it (on-press/on-double-press/on-toggle/on-hold/on-drag are fine anywhere: they make any element interactive, and presses on plain text or icons inside it fall through to it)";

/// Elements whose widget kind layers its children on top of each other
Expand Down Expand Up @@ -3433,6 +3436,22 @@ fn validateNode(document: MarkupDocument, node: MarkupNode, parent_element: ?[]c
}
continue;
}
if (std.mem.eql(u8, attribute.name, "auto-height-min") or std.mem.eql(u8, attribute.name, "auto-height-max")) {
if (!std.mem.eql(u8, node.name, "textarea") or node.attr("height") != null)
return attrError(node, attribute, auto_height_element_message);
if (attrExpressionError(attribute.value, invalid_expression_message)) |message| {
return attrError(node, attribute, message);
}
const expression = parseAttrExpression(attribute.value) orelse
return attrError(node, attribute, invalid_expression_message);
if (expression == .literal) {
const value = std.fmt.parseFloat(f32, expression.literal) catch
return attrError(node, attribute, auto_height_value_message);
if (!std.math.isFinite(value) or value < 0)
return attrError(node, attribute, auto_height_value_message);
}
continue;
}
if (std.mem.eql(u8, attribute.name, "submit-on-enter")) {
// Only textarea has two meaningful Enter gestures:
// submit or insert a newline. Single-line fields
Expand Down
18 changes: 16 additions & 2 deletions src/primitives/canvas/ui_markup_compiled.zig
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,16 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
}
}
}
for (node.attrs) |attribute| {
if (!std.mem.eql(u8, attribute.name, "auto-height-min") and !std.mem.eql(u8, attribute.name, "auto-height-max")) continue;
if (kind != .textarea or node.attr("height") != null) fail(node, markup.auto_height_element_message);
const expression = markup.parseAttrExpression(attribute.value) orelse continue;
if (expression == .literal) {
const value = std.fmt.parseFloat(f32, expression.literal) catch
fail(node, markup.auto_height_value_message);
if (!std.math.isFinite(value) or value < 0) fail(node, markup.auto_height_value_message);
}
}
// Interpreter parity: the overflow policy's closed
// literal vocabulary. A compile error here; bindings
// resolve at runtime like any enum option.
Expand Down Expand Up @@ -3433,9 +3443,13 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
const value = attrExprValue(node, entries, attribute_name, raw, ui, model, scope);
@field(options, zig_field) = runtime_values.convert(Target, owner, value, false) orelse runtimeFail(Target, ui);
}
return;
} else {
setOptionReference(node, zig_field, attribute_name, raw, entries, ui, model, scope, options);
}
if (comptime std.mem.eql(u8, zig_field, "auto_height_min") or std.mem.eql(u8, zig_field, "auto_height_max")) {
const value = @field(options, zig_field);
if (!std.math.isFinite(value) or value < 0) @field(options, zig_field) = runtimeFail(f32, ui);
}
setOptionReference(node, zig_field, attribute_name, raw, entries, ui, model, scope, options);
}

fn setOptionReference(comptime node: markup.MarkupNode, comptime zig_field: []const u8, comptime attribute_name: []const u8, comptime raw: []const u8, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype, options: *Ui.ElementOptions) void {
Expand Down
40 changes: 40 additions & 0 deletions src/primitives/canvas/ui_markup_compiled_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,46 @@ const fixture = @import("ui_markup_view_tests.zig");

const testing = std.testing;

test "textarea content height rejects negative and nonfinite evaluated bounds" {
const Model = struct { bound: f32 };
const Msg = union(enum) { noop };
inline for (.{ "<textarea label=\"Draft\" auto-height-min=\"{bound}\" />", "<textarea label=\"Draft\" auto-height-max=\"{bound}\" />" }) |source| {
for ([_]f32{ -1, std.math.inf(f32), -std.math.inf(f32), std.math.nan(f32) }) |bound| {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var interpreted = try canvas.MarkupView(Model, Msg).init(arena.allocator(), source);
var native_ui = canvas.Ui(Msg).init(arena.allocator());
var compiled_ui = canvas.Ui(Msg).init(arena.allocator());
const model = Model{ .bound = bound };
try testing.expectError(error.MarkupBuild, interpreted.build(&native_ui, &model));
try testing.expectEqualStrings(@import("ui_markup.zig").auto_height_value_message, interpreted.diagnostic.message);
const node = canvas.CompiledMarkupView(Model, Msg, source).build(&compiled_ui, &model);
try testing.expect(compiled_ui.failed);
try testing.expectError(error.OutOfMemory, compiled_ui.finalize(node));
}
}
}

test "compiled textarea content height matches interpreted numeric bindings" {
const Model = struct { ceiling: f32 = 240 };
const Msg = union(enum) { noop };
const Ui = canvas.Ui(Msg);
const source = "<textarea label=\"Draft\" auto-height-min=\"44\" auto-height-max=\"{ceiling}\" text=\"First line\" />";
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var model = Model{};
var interpreted = try canvas.MarkupView(Model, Msg).init(arena.allocator(), source);
const Compiled = canvas.CompiledMarkupView(Model, Msg, source);
var native_ui = Ui.init(arena.allocator());
var compiled_ui = Ui.init(arena.allocator());
const expected = try native_ui.finalize(try interpreted.build(&native_ui, &model));
const actual = try compiled_ui.finalize(Compiled.build(&compiled_ui, &model));
try testing.expectEqual(@as(f32, 44), actual.root.auto_height_min);
try testing.expectEqual(@as(f32, 240), actual.root.auto_height_max);
try testing.expectEqual(expected.root.auto_height_min, actual.root.auto_height_min);
try testing.expectEqual(expected.root.auto_height_max, actual.root.auto_height_max);
}

test "windowed markup preserves complete ranges keyed rows and retained declarations in both engines" {
const Row = struct { id: usize, label: []const u8 };
const Model = struct {
Expand Down
Loading