From 5b73bbbadf1367453f62dd89e29930819a893648 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:33:52 -0500 Subject: [PATCH 1/5] Strip line breaks from single-line text-field inserts at the edit seam - Sanitize derived edits for input/text_field/search_field/combobox at the keyboard choke point, BEFORE the stamp, so the retained editor, the app's on_input mirror, and replay all hear identical stripped bytes (HTML value-sanitization rule; covers shortcut and context-menu paste, typed/automation text_input, and IME composition). - Suppress inserts that strip to nothing so pasting bare newlines inserts nothing and a host-stuffed Enter payload never deletes a live selection; the app-side fallback derivation applies the same rule so both derivations agree. - Sanitize direct runtime editCanvasWidgetText writes through the same shared rule. Co-authored-by: IFTC-XLKJ <151902522+IFTC-XLKJ@users.noreply.github.com> --- src/primitives/canvas/events.zig | 161 +++++++++++++++++++++++++++ src/primitives/canvas/root.zig | 2 + src/primitives/canvas/ui.zig | 13 ++- src/runtime/canvas_widget_events.zig | 16 ++- src/runtime/canvas_widget_state.zig | 7 +- 5 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/primitives/canvas/events.zig b/src/primitives/canvas/events.zig index 8f9811adc..a383661ad 100644 --- a/src/primitives/canvas/events.zig +++ b/src/primitives/canvas/events.zig @@ -119,6 +119,108 @@ pub fn widgetKeyboardNewlineTextEditEvent(kind: WidgetKind, event: WidgetKeyboar return .{ .insert_text = "\n" }; } +/// The single-line text-entry kinds: their value can never hold a line +/// break (Enter submits instead of editing — see +/// `widgetKeyboardNewlineTextEditEvent`), so text inserted into them +/// sanitizes through `sanitizedSingleLineTextInputEvent`. The textarea is +/// the one genuinely multi-line editable kind and stays out. +pub fn widgetKindSingleLineTextEntry(kind: WidgetKind) bool { + return kind == .input or kind == .text_field or kind == .search_field or kind == .combobox; +} + +/// Sanitized-edit scratch: the rewritten insert bytes live here until the +/// next edit that needs rewriting. Sound for the same reason the runtime's +/// paste buffer is: the event loop is single-threaded, at most one +/// insert-bearing edit is derived per dispatched input, and every consumer +/// (retained editor apply, the app's `on_input` Msg, model mirrors) reads +/// the stamped bytes synchronously within that dispatch. Sized to the +/// runtime's per-view widget-text budget +/// (`max_canvas_widget_text_bytes_per_view`), the largest insert the +/// editor could accept anyway. +const max_sanitized_text_edit_bytes: usize = 65536; +const SanitizedTextEditScratch = struct { + bytes: [max_sanitized_text_edit_bytes]u8, +}; +const sanitized_text_edit_scratch = @import("lazy_tls.zig").LazyTls(SanitizedTextEditScratch); + +fn textContainsLineBreakByte(text: []const u8) bool { + // Raw byte scan is UTF-8 safe: 0x0A/0x0D never appear inside a + // multibyte sequence. + return std.mem.indexOfAny(u8, text, "\r\n") != null; +} + +/// The ONE sanitization rule for text entering a single-line field, at +/// the edit-derivation seam every insertion source flows through +/// (clipboard paste — shortcut and context menu —, typed/automation +/// `text_input`, IME composition, and the app-side fallback derivation): +/// +/// line breaks are STRIPPED from inserted text — U+000A and U+000D +/// removed outright, lines joined with nothing between them. +/// +/// This is the HTML value sanitization algorithm for single-line inputs +/// ("Strip newlines from the value", +/// https://html.spec.whatwg.org/multipage/input.html), which is also what +/// Chromium does when pasting multi-line text into an `` — the +/// dominant convention. (WebKit historically substituted spaces; there is +/// no spec for the paste path itself, so the value-sanitization rule +/// wins.) +/// +/// Contracts, in declaration order: +/// - multi-line kinds (textarea) and non-insert edits pass through +/// untouched; +/// - an insert that strips to NOTHING is suppressed (null): pasting +/// bare newlines inserts nothing and never eats a live selection, +/// and an Enter whose host stuffed "\r"/"\n" into the key event +/// stays not-an-insert; +/// - a composition update strips the same way but an EMPTY result is +/// kept (an empty preview is meaningful — it clears the previous +/// one), with the preview cursor shifted left past the removed +/// bytes, so the IME COMMIT (which lands whatever the preview +/// holds) can never commit a line break into a single-line field; +/// - an insert too large for the scratch passes through untouched — +/// the editor apply rejects over-budget inserts loudly anyway. +/// +/// Deterministic derivation: the session journal records the RAW +/// platform event; replaying it re-derives the identical sanitized edit +/// here, so recorded multi-line pastes replay byte-identically. +pub fn sanitizedSingleLineTextInputEvent(kind: WidgetKind, event: TextInputEvent) ?TextInputEvent { + if (!widgetKindSingleLineTextEntry(kind)) return event; + switch (event) { + .insert_text => |text| { + if (!textContainsLineBreakByte(text)) return event; + if (text.len > max_sanitized_text_edit_bytes) return event; + const stripped = stripLineBreakBytes(text, &sanitized_text_edit_scratch.get().bytes); + if (stripped.len == 0) return null; + return .{ .insert_text = stripped }; + }, + .set_composition => |composition| { + if (!textContainsLineBreakByte(composition.text)) return event; + if (composition.text.len > max_sanitized_text_edit_bytes) return event; + const cursor = @min(composition.cursor orelse composition.text.len, composition.text.len); + var stripped_cursor: usize = cursor; + for (composition.text[0..cursor]) |byte| { + if (byte == '\n' or byte == '\r') stripped_cursor -= 1; + } + const stripped = stripLineBreakBytes(composition.text, &sanitized_text_edit_scratch.get().bytes); + return .{ .set_composition = .{ + .text = stripped, + .cursor = if (composition.cursor == null and stripped_cursor == stripped.len) null else stripped_cursor, + } }; + }, + else => return event, + } +} + +fn stripLineBreakBytes(text: []const u8, buffer: []u8) []const u8 { + var len: usize = 0; + for (text) |byte| { + if (byte == '\n' or byte == '\r') continue; + buffer[len] = byte; + len += 1; + } + return buffer[0..len]; +} + /// The clipboard intent of a key event: cmd+C/X/V on macOS, ctrl+C/X/V /// elsewhere (`hasCommandModifier` covers both). Shift/alt variants are /// deliberately excluded so shift+ctrl+V-style paste-special chords stay @@ -694,3 +796,62 @@ pub fn defaultFocusable(widget: Widget) bool { else => false, }; } + +test "sanitizedSingleLineTextInputEvent strips line breaks per the HTML value-sanitization rule" { + const testing = std.testing; + // Interior LF, CR, and CRLF all strip outright — lines join with + // nothing between them (the Chromium paste behavior). + const pasted = sanitizedSingleLineTextInputEvent(.input, .{ .insert_text = "alpha\nbeta\r\ngamma\r" }).?; + try testing.expectEqualStrings("alphabetagamma", pasted.insert_text); + + // Every single-line kind sanitizes; the textarea keeps its breaks. + for ([_]WidgetKind{ .input, .text_field, .search_field, .combobox }) |kind| { + const stripped = sanitizedSingleLineTextInputEvent(kind, .{ .insert_text = "a\nb" }).?; + try testing.expectEqualStrings("ab", stripped.insert_text); + } + const textarea = sanitizedSingleLineTextInputEvent(.textarea, .{ .insert_text = "a\nb" }).?; + try testing.expectEqualStrings("a\nb", textarea.insert_text); + + // Break-free inserts pass through as the SAME slice (zero copy), and + // the deliberately-empty insert (cut's delete-selection) survives. + const clean: TextInputEvent = .{ .insert_text = "plain" }; + try testing.expectEqual(clean.insert_text.ptr, sanitizedSingleLineTextInputEvent(.input, clean).?.insert_text.ptr); + const cut = sanitizedSingleLineTextInputEvent(.input, .{ .insert_text = "" }).?; + try testing.expectEqualStrings("", cut.insert_text); + + // An insert that is ONLY line breaks suppresses: pasting bare + // newlines inserts nothing, and an Enter whose host stuffed "\r" + // into the key event stays not-an-insert. + try testing.expect(sanitizedSingleLineTextInputEvent(.input, .{ .insert_text = "\r\n\n" }) == null); + + // Non-insert edits pass through untouched. + const moved = sanitizedSingleLineTextInputEvent(.input, .{ .move_caret = .{ .direction = .end } }).?; + try testing.expect(moved.move_caret.direction == .end); +} + +test "sanitizedSingleLineTextInputEvent strips composition text and shifts the preview cursor" { + const testing = std.testing; + // "ab\ncd" with the cursor after "cd" (offset 5): the stripped + // preview is "abcd" with the cursor at 4. + const preview = sanitizedSingleLineTextInputEvent(.combobox, .{ .set_composition = .{ .text = "ab\ncd", .cursor = 5 } }).?; + try testing.expectEqualStrings("abcd", preview.set_composition.text); + try testing.expectEqual(@as(usize, 4), preview.set_composition.cursor.?); + + // A cursor BEFORE the break does not shift. + const early = sanitizedSingleLineTextInputEvent(.search_field, .{ .set_composition = .{ .text = "ab\ncd", .cursor = 2 } }).?; + try testing.expectEqual(@as(usize, 2), early.set_composition.cursor.?); + + // A null cursor (end-of-preview) stays null. + const tail = sanitizedSingleLineTextInputEvent(.input, .{ .set_composition = .{ .text = "a\r\nb" } }).?; + try testing.expectEqualStrings("ab", tail.set_composition.text); + try testing.expect(tail.set_composition.cursor == null); + + // An all-breaks preview is KEPT as the empty preview (it clears the + // previous one) rather than suppressed. + const cleared = sanitizedSingleLineTextInputEvent(.input, .{ .set_composition = .{ .text = "\n" } }).?; + try testing.expectEqualStrings("", cleared.set_composition.text); + + // A textarea preview keeps its newline. + const multi = sanitizedSingleLineTextInputEvent(.textarea, .{ .set_composition = .{ .text = "a\nb" } }).?; + try testing.expectEqualStrings("a\nb", multi.set_composition.text); +} diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 42335e088..d9b1dc3ad 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -566,6 +566,8 @@ pub const WidgetInvalidation = event_model.WidgetInvalidation; pub const WidgetClipboardAction = event_model.WidgetClipboardAction; pub const widgetKeyboardClipboardAction = event_model.widgetKeyboardClipboardAction; pub const widgetKeyboardNewlineTextEditEvent = event_model.widgetKeyboardNewlineTextEditEvent; +pub const widgetKindSingleLineTextEntry = event_model.widgetKindSingleLineTextEntry; +pub const sanitizedSingleLineTextInputEvent = event_model.sanitizedSingleLineTextInputEvent; pub const widgetKeyboardControlIntent = event_model.widgetKeyboardControlIntent; pub const semanticActions = event_model.semanticActions; pub const widgetSemanticControlIntent = event_model.widgetSemanticControlIntent; diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index 91ad95560..15dee210f 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -1170,7 +1170,18 @@ pub fn Ui(comptime Msg: type) type { else canvas.widgetKeyboardNewlineTextEditEvent(widget.kind, keyboard) orelse keyboard.textEditEvent(); if (edit) |text_edit| { - if (self.msgForTextEdit(target_id, text_edit)) |msg| return msg; + // Single-line kinds sanitize the edit with the + // SAME rule the runtime seam applies before + // stamping (strip line breaks; suppress inserts + // that strip to nothing), so the fallback + // derivation for events that never crossed the + // runtime can never feed a model mirror bytes + // the retained editor would refuse. Stamped + // edits are already sanitized and pass through + // untouched. + if (canvas.sanitizedSingleLineTextInputEvent(widget.kind, text_edit)) |sanitized| { + if (self.msgForTextEdit(target_id, sanitized)) |msg| return msg; + } } } return null; diff --git a/src/runtime/canvas_widget_events.zig b/src/runtime/canvas_widget_events.zig index 17552c933..cc95c333b 100644 --- a/src/runtime/canvas_widget_events.zig +++ b/src/runtime/canvas_widget_events.zig @@ -1877,7 +1877,21 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type { const index = runtimeFindViewIndex(self, keyboard_event.window_id, keyboard_event.view_label) orelse return; if (self.views[index].kind != .gpu_surface) return; const target = keyboard_event.target orelse return; - const edit = self.views[index].canvasWidgetKeyboardTextEdit(target, keyboard_event.keyboard) orelse return; + const derived = self.views[index].canvasWidgetKeyboardTextEdit(target, keyboard_event.keyboard) orelse return; + // Single-line sanitization happens HERE — after derivation, + // BEFORE the stamp — so the retained editor and the app's + // `on_input` mirror hear byte-identical sanitized inserts + // (clipboard paste from both entry points, typed and + // automation text_input, IME composition — every insertion + // source flows through this one seam). A suppressed edit (an + // insert that was ONLY line breaks) also clears any raw + // pre-stamped paste so the app can never hear bytes the + // editor refused; the app-side fallback derivation applies + // the same sanitize rule, so both derivations still agree. + const edit = canvas.sanitizedSingleLineTextInputEvent(target.kind, derived) orelse { + keyboard_event.keyboard.edit = null; + return; + }; keyboard_event.keyboard.edit = edit; const dirty = try self.views[index].applyCanvasWidgetTextEdit(target.id, edit) orelse return; diff --git a/src/runtime/canvas_widget_state.zig b/src/runtime/canvas_widget_state.zig index 184bbbc5e..61fcfeb1b 100644 --- a/src/runtime/canvas_widget_state.zig +++ b/src/runtime/canvas_widget_state.zig @@ -819,7 +819,12 @@ pub fn RuntimeCanvasWidgetState(comptime Runtime: type) type { if (self.views[index].kind != .gpu_surface) return error.InvalidViewOptions; if (!self.views[index].canEditCanvasWidgetText(id)) return error.InvalidCommand; - const dirty = try self.views[index].applyCanvasWidgetTextEdit(id, edit) orelse return self.views[index].info(); + // Direct runtime edits sanitize like every other insertion + // source (the keyboard choke point's rule): a single-line + // field's editor never accepts a line break, whoever writes. + const node = self.views[index].widgetLayoutTree().findById(id) orelse return error.InvalidCommand; + const sanitized = canvas.sanitizedSingleLineTextInputEvent(node.widget.kind, edit) orelse return self.views[index].info(); + const dirty = try self.views[index].applyCanvasWidgetTextEdit(id, sanitized) orelse return self.views[index].info(); try CanvasWidgetEventMethods(Runtime).invalidateForCanvasWidgetDirty(self, index, dirty); _ = try CanvasWidgetDisplayMethods(Runtime).refreshCanvasWidgetDisplayListIfOwned(self, index); return self.views[index].info(); From 53027cd72c5c8ef640ab4b9d4e5147ed7003765c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:34:02 -0500 Subject: [PATCH 2/5] Present line-broken single-line values as one clipped line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lay out, measure, and paint a single-line field's value with \n/\r presented as spaces (byte-for-byte, so caret/selection/hit-test offsets address the raw value) — one line on the GPU engine, the reference renderer, and packet hosts alike; copy, semantics, and automation still read the raw model value. - Force the content-rect clip whenever the raw value holds a line break, the independent guard that keeps even a presentation-scratch fallback inside the field's rounded border. - Persist presented bytes into a render-walk pool (the chart-label scratch precedent) so emitted commands survive until the runtime copies the display list. Co-authored-by: IFTC-XLKJ <151902522+IFTC-XLKJ@users.noreply.github.com> --- src/primitives/canvas/widget_render.zig | 2 + .../canvas/widget_render_controls.zig | 17 ++- src/primitives/canvas/widget_text_input.zig | 142 ++++++++++++++++-- 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index 30b0e17e1..d9a2e485b 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -162,6 +162,7 @@ threadlocal var scrim_viewport: ?geometry.RectF = null; pub fn emitWidgetTree(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { resetFrameLabelScratch(); + widget_text_input.resetWidgetTextInputPresentationPool(); scrim_viewport = widget.frame.normalized(); try emitWidgetDepth(builder, widget, tokens, 0); } @@ -172,6 +173,7 @@ pub fn emitWidgetLayout(builder: *Builder, layout: anytype, tokens: DesignTokens pub fn emitWidgetLayoutWithState(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void { resetFrameLabelScratch(); + widget_text_input.resetWidgetTextInputPresentationPool(); scrim_viewport = widgetLayoutRootBounds(layout); try emitWidgetLayoutChildren(builder, layout, null, tokens, state); try emitWidgetLayoutAnchored(builder, layout, tokens, state); diff --git a/src/primitives/canvas/widget_render_controls.zig b/src/primitives/canvas/widget_render_controls.zig index 975c8abc6..332635c48 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -463,7 +463,11 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke const clip_rect = widgetTextInputClipRect(widget, tokens, text_size, text_inset, layout_options); const origin = widgetTextInputOrigin(widget, tokens, text_size, text_inset, layout_options); const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text); - const draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); + var draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); + // A presented value (single-line kind holding a line break) lives in + // shared scratch; persist it so this widget's emitted commands + // survive the rest of the walk. No-op for the ordinary raw value. + draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(widget.text, draw_text.text); const selection_range = widgetTextSelectionRange(widget); const composition_range = widgetTextCompositionRange(widget); const has_text_affordances = selection_range != null or composition_range != null; @@ -497,7 +501,9 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke } } const placeholder = widgetPlaceholder(widget); - const visible_text = if (widget.text.len > 0) widget.text else placeholder; + // A non-empty value paints the presented bytes the draw text already + // carries; only an empty field swaps in the placeholder. + const visible_text = if (widget.text.len > 0) draw_text.text else placeholder; if (visible_text.len > 0) { var command = draw_text; command.id = widgetPartId(widget.id, if (has_text_affordances) 4 else 3); @@ -566,7 +572,10 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo const selection_range = widgetTextSelectionRange(widget); const composition_range = widgetTextCompositionRange(widget); const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text); - const draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); + var draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); + // Presented values persist out of the shared scratch — the + // text-field emitter's rule (no-op for ordinary raw values). + draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(widget.text, draw_text.text); // Same overflow contract as the text-field emitter: clip only once // the value (or placeholder) overflows the content rect, so the // horizontally scrolled text and its affordances cut at the border. @@ -600,7 +609,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo } } const placeholder = widgetPlaceholder(widget); - const visible_text = if (widget.text.len > 0) widget.text else placeholder; + const visible_text = if (widget.text.len > 0) draw_text.text else placeholder; if (visible_text.len > 0) { var command = draw_text; command.id = widgetPartId(widget.id, 9); diff --git a/src/primitives/canvas/widget_text_input.zig b/src/primitives/canvas/widget_text_input.zig index 3e71beddf..4faf944fd 100644 --- a/src/primitives/canvas/widget_text_input.zig +++ b/src/primitives/canvas/widget_text_input.zig @@ -36,6 +36,111 @@ pub fn widgetPlaceholder(widget: Widget) []const u8 { }; } +// --------------------------------------------------------------------------- +// Single-line presentation: a single-line field NEVER paints a second line. +// +// Edits into single-line kinds are sanitized at the runtime's derivation +// seam (`sanitizedSingleLineTextInputEvent`), but the retained VALUE can +// still legitimately hold a line break — a model-set value, an old journal, +// a host API write. The honest presentation for such a value is the one +// the paint layers already give whitespace: `\n`/`\r` lay out and measure +// as a SPACE (nothing inked, one space advance) instead of breaking the +// line. The substitution is byte-for-byte (1 → 1), so every caret, +// selection, composition, and hit-test offset computed against the +// presented bytes addresses the raw value unchanged — and because the +// presented bytes contain no `\n`, the single-line layout (`wrap = .none`) +// produces exactly one line on every renderer: the GPU engine, the +// reference renderer, and packet hosts that draw engine-broken lines +// verbatim. Copy, semantics, and automation still read the RAW value. +// +// The clip is the independent second guard: `widgetTextInputClipsText` +// forces the content-rect clip whenever the raw value holds a break, so +// even a path that somehow lays out raw bytes (presentation scratch +// exhausted) stays inside the field's border. + +/// The editable kinds that present single-line: every text-input kind but +/// the textarea (the one genuinely multi-line editor). Deliberately +/// includes the tall wrapping `text_field` variant — its kind contract is +/// single-line entry, and `\n`-as-space is consistent under word wrap too. +fn widgetTextInputPresentsSingleLine(kind: widget_model.WidgetKind) bool { + return kind != .textarea and widget_access.widgetTextInputKind(kind); +} + +fn textContainsLineBreakByte(text: []const u8) bool { + return std.mem.indexOfAny(u8, text, "\r\n") != null; +} + +/// Whether this widget's PAINTED text differs from its raw value (a +/// single-line kind whose value holds a line break). Render emitters use +/// it to persist the presented bytes for the display list's lifetime. +pub fn widgetTextInputTextNeedsPresentation(widget: Widget) bool { + return widgetTextInputPresentsSingleLine(widget.kind) and textContainsLineBreakByte(widget.text); +} + +/// Presentation scratch, lazily heap-allocated per thread (the +/// `lazy_tls` pattern all canvas scratch uses; the event loop is +/// single-threaded): +/// - `slot` holds ONE presented text at a time — the transient buffer +/// the geometry seams (caret, selection, hit-test, scroll measures) +/// substitute into. Every entry point re-derives it, and within one +/// widget's computation repeated derivations write identical bytes, +/// so the aliasing is harmless. +/// - `pool` appends presented texts for the RENDER walk: emitted +/// `drawText` commands slice into it and must survive until the +/// runtime copies the display list (same emit call stack — the +/// chart-label scratch precedent). Reset at each emit entry point. +/// Sized to the runtime's per-view budgets: `slot` to the widget-text +/// budget (65536), `pool` to the per-frame text budget (32768) — a frame +/// retaining more text fails its own budget first. Overflow falls back +/// to the RAW bytes; the forced clip above keeps even that fallback +/// inside the field's border. +const WidgetTextPresentationScratch = struct { + slot: [65536]u8, + pool: [32768]u8, + pool_len: usize = 0, +}; +const widget_text_presentation_scratch = @import("lazy_tls.zig").LazyTls(WidgetTextPresentationScratch); + +/// Reset the render walk's presentation pool — called by the display-list +/// emit entry points, next to the chart-label scratch reset. +pub fn resetWidgetTextInputPresentationPool() void { + widget_text_presentation_scratch.get().pool_len = 0; +} + +/// The text a single-line field lays out, measures, and paints: the raw +/// value with `\n`/`\r` presented as spaces (same byte length). Returns +/// the raw slice untouched when nothing needs presenting — the +/// overwhelmingly common case costs one byte scan. +pub fn widgetTextInputPresentedText(widget: Widget) []const u8 { + if (!widgetTextInputPresentsSingleLine(widget.kind)) return widget.text; + return presentedSingleLineText(widget.text); +} + +fn presentedSingleLineText(text: []const u8) []const u8 { + if (!textContainsLineBreakByte(text)) return text; + const scratch = widget_text_presentation_scratch.get(); + if (text.len > scratch.slot.len) return text; + for (text, 0..) |byte, index| { + scratch.slot[index] = if (byte == '\n' or byte == '\r') ' ' else byte; + } + return scratch.slot[0..text.len]; +} + +/// Persist presented bytes into the render walk's pool so an emitted +/// command outlives the shared slot (a later widget's presentation +/// overwrites it). Falls back to the RAW value — stable, view-owned +/// storage — when the pool is full; the forced clip contains that +/// fallback inside the field's border. +pub fn persistWidgetTextInputPresentedText(raw: []const u8, presented: []const u8) []const u8 { + if (presented.ptr == raw.ptr) return raw; + const scratch = widget_text_presentation_scratch.get(); + if (scratch.pool_len + presented.len > scratch.pool.len) return raw; + const start = scratch.pool_len; + scratch.pool_len += presented.len; + @memcpy(scratch.pool[start..scratch.pool_len], presented); + return scratch.pool[start..scratch.pool_len]; +} + pub fn textSelectionForWidgetPoint(widget: Widget, point: geometry.PointF, anchor: ?usize, tokens: DesignTokens) ?TextSelection { const offset = textOffsetForWidgetPoint(widget, point, tokens) orelse return null; const selection = if (anchor) |anchor_offset| @@ -115,7 +220,10 @@ fn widgetTextInputMaxHorizontalScrollOffset(widget: Widget, tokens: DesignTokens if (options.wrap != .none or widget.text.len == 0) return 0; const viewport = widgetTextInputClipRect(widget, tokens, text_size, text_inset, options); if (viewport.width <= 0) return 0; - const text_width = measureTextWidthForFont(options.measure, tokens.typography.font_id, widget.text, text_size); + // Measure the PRESENTED bytes — the ones the field lays out and + // paints — so a value holding a line break scrolls by the same + // single-line extent it draws. + const text_width = measureTextWidthForFont(options.measure, tokens.typography.font_id, widgetTextInputPresentedText(widget), text_size); return @max(0, text_width + text_input_caret_reserve - viewport.width); } @@ -220,9 +328,10 @@ pub fn textInputCaretVisibleScrollOffsetForWidget(widget: Widget, tokens: Design // Caret x in content (unscrolled) coordinates: the width of the value // up to the caret byte, measured on the same seam the line layout and - // selection rects measure with. + // selection rects measure with — the PRESENTED bytes (byte offsets + // are interchangeable: the presentation substitutes 1:1). const caret_offset = snapTextOffset(widget.text, selection.focus); - const caret_x = measureTextWidthForFont(options.measure, tokens.typography.font_id, widget.text[0..caret_offset], text_size); + const caret_x = measureTextWidthForFont(options.measure, tokens.typography.font_id, widgetTextInputPresentedText(widget)[0..caret_offset], text_size); const margin = textInputCaretVisibleMargin(viewport.width); var next = clamped; const scrolled_in = caret_x + text_input_caret_reserve + margin - viewport.width; @@ -239,6 +348,11 @@ pub fn textInputCaretVisibleScrollOffsetForWidget(widget: Widget, tokens: Design /// exactly as before fields scrolled. pub fn widgetTextInputClipsText(widget: Widget, tokens: DesignTokens, text_size: f32, text_inset: f32, options: TextLayoutOptions) bool { if (widget.kind == .textarea) return true; + // A raw value holding a line break ALWAYS clips: presentation lays it + // out as one line, but the clip is the independent guard that keeps + // even a raw-bytes fallback (presentation scratch exhausted) from + // painting a second line outside the field's border. + if (widgetTextInputTextNeedsPresentation(widget)) return true; if (options.wrap != .none) return false; if (widgetTextInputMaxHorizontalScrollOffset(widget, tokens, text_size, text_inset, options) > 0) return true; if (widget.text.len == 0) { @@ -260,16 +374,19 @@ fn textInputContentExtentForWidgetWithOptions(widget: Widget, font_id: FontId, t } fn widgetTextInputLineCount(widget: Widget, font_id: FontId, text_size: f32, options: TextLayoutOptions) usize { - if (widget.text.len == 0) return 1; + // Count lines over the PRESENTED bytes: a single-line field's value + // holding a `\n` still lays out (and therefore extends) as one line. + const text = widgetTextInputPresentedText(widget); + if (text.len == 0) return 1; var count: usize = 0; var start: usize = 0; - while (start <= widget.text.len) { - const end = nextTextLineEnd(widget.text, start, font_id, text_size, options); + while (start <= text.len) { + const end = nextTextLineEnd(text, start, font_id, text_size, options); count += 1; - if (end >= widget.text.len) break; + if (end >= text.len) break; start = end; - if (start < widget.text.len and widget.text[start] == '\n') start += 1; - while (options.wrap == .word and start < widget.text.len and isTextBreakByte(widget.text[start])) start += 1; + if (start < text.len and text[start] == '\n') start += 1; + while (options.wrap == .word and start < text.len and isTextBreakByte(text[start])) start += 1; } return @max(1, count); } @@ -287,7 +404,12 @@ pub fn widgetTextInputDrawText( .size = text_size, .origin = pixelSnapTextPoint(tokens, origin), .color = color, - .text = widget.text, + // The presented bytes: identical to `widget.text` unless a + // single-line field's value holds a line break (then `\n`/`\r` + // present as spaces — one line, same byte offsets). Geometry + // consumers (caret, selection, hit-test) and the paint emitters + // both build from this seam, so they can never disagree. + .text = widgetTextInputPresentedText(widget), .text_layout = options, }; } From 5fabc30bd42b72b4ed709b2488b0116aa0205e48 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 01:34:16 -0500 Subject: [PATCH 3/5] Pin single-line newline sanitization and containment across the batteries - Paste, automation set_text, IME composition, and the Tree fallback derivation all pin stripped inserts (textarea keeps its breaks); bare-newline pastes insert nothing and Enter stays not-an-insert even with a host-stuffed newline payload. - Model-set values holding a newline pin one presented line under a forced clip in both render walks and through the runtime display list, with semantics still reporting the raw value. - The session reference recording now copies a textarea's two lines and pastes them into the search field, pinning that a recorded multi-line paste replays to the identical sanitized value and fingerprint. Co-authored-by: IFTC-XLKJ <151902522+IFTC-XLKJ@users.noreply.github.com> --- changelog.d/single-line-paste-newlines.md | 2 + src/primitives/canvas/ui_tests.zig | 30 ++ .../canvas/widget_builtin_tests.zig | 84 ++++++ src/runtime/canvas_widget_clipboard_tests.zig | 77 +++++ src/runtime/canvas_widget_text_tests.zig | 265 ++++++++++++++++++ src/runtime/session_tests.zig | 63 ++++- 6 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 changelog.d/single-line-paste-newlines.md diff --git a/changelog.d/single-line-paste-newlines.md b/changelog.d/single-line-paste-newlines.md new file mode 100644 index 000000000..920956cde --- /dev/null +++ b/changelog.d/single-line-paste-newlines.md @@ -0,0 +1,2 @@ +fix: **Single-line fields never hold or paint line breaks**: pasting multi-line text into an input, text field, search field, or combobox now strips the line breaks at the edit seam (the HTML value-sanitization rule — lines join with nothing between them), covering clipboard paste from the shortcut and the context menu, typed and automation `text_input`, and IME composition, with the app's `on_input` hearing the same sanitized bytes the editor applied; a paste of only newlines inserts nothing. +- **Defensive render containment**: a single-line value that still holds a `\n` (a model-set value, an old journal) now paints as one line — breaks present as spaces — under a forced content-rect clip, so text can never escape the field's rounded border on any renderer. diff --git a/src/primitives/canvas/ui_tests.zig b/src/primitives/canvas/ui_tests.zig index b4d271f48..ec24ab7d9 100644 --- a/src/primitives/canvas/ui_tests.zig +++ b/src/primitives/canvas/ui_tests.zig @@ -354,6 +354,36 @@ test "textarea keyboard: enter edits a newline, submit rides the primary chord" try testing.expectEqual(@as(?Msg, null), tree.msgForKeyboard(textarea.id, alt_enter)); } +test "single-line keyboard fallback derivation sanitizes line breaks like the runtime seam" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var ui = InboxUi.init(arena_state.allocator()); + const tree = try ui.finalize(ui.column(.{ .gap = 8 }, .{ + ui.el(.text_field, .{ .on_input = InboxUi.inputMsg(.draft) }, .{}), + ui.el(.textarea, .{ .on_input = InboxUi.inputMsg(.draft) }, .{}), + })); + const field = findByKind(tree.root, .text_field).?; + const textarea = findByKind(tree.root, .textarea).?; + + // An un-stamped text_input event (a direct Tree consumer — nothing + // crossed the runtime) derives its insert locally, and the local + // derivation applies the SAME single-line sanitize rule the runtime + // seam stamps with: line breaks strip, so a model mirror can never + // hear bytes the retained editor would refuse. + const multi_line = canvas.WidgetKeyboardEvent{ .phase = .text_input, .text = "a\nb\r\nc" }; + try testing.expectEqualStrings("abc", tree.msgForKeyboard(field.id, multi_line).?.draft.insert_text); + try testing.expectEqualStrings("a\nb\r\nc", tree.msgForKeyboard(textarea.id, multi_line).?.draft.insert_text); + + // An insert that is ONLY breaks suppresses outright. + const bare_breaks = canvas.WidgetKeyboardEvent{ .phase = .text_input, .text = "\r\n" }; + try testing.expectEqual(@as(?Msg, null), tree.msgForKeyboard(field.id, bare_breaks)); + + // A STAMPED edit (the runtime already sanitized it) passes through. + const stamped = canvas.WidgetKeyboardEvent{ .phase = .key_down, .edit = .{ .insert_text = "clean" } }; + try testing.expectEqualStrings("clean", tree.msgForKeyboard(field.id, stamped).?.draft.insert_text); +} + test "typed handlers imply accessibility actions" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/primitives/canvas/widget_builtin_tests.zig b/src/primitives/canvas/widget_builtin_tests.zig index 2190a095c..3fa58f87b 100644 --- a/src/primitives/canvas/widget_builtin_tests.zig +++ b/src/primitives/canvas/widget_builtin_tests.zig @@ -3977,6 +3977,90 @@ test "short single-line values emit no clip and an unshifted origin" { } } +test "a single-line value holding a line break renders one line inside the border" { + // The defensive containment layer: edits can never insert a line + // break into a single-line field (they sanitize at the derivation + // seam), but a MODEL-SET value still can — a Zig core can put one + // there today, as can an old journal or a host API write. The field + // presents `\n`/`\r` as spaces (one line, byte-for-byte offsets) and + // force-clips to its content rect, so the value never paints outside + // the rounded border — identically in both render walks. + const field = Widget{ + .id = 7, + .kind = .input, + .frame = geometry.RectF.init(10, 12, 200, 32), + .text = "one\ntwo\r\nthree", + .semantics = .{ .label = "Name" }, + }; + + var commands: [8]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, field, .{}); + try expectSingleLinePresentedField(builder.displayList(), field); + + // Layout walk: the compiled and interpreted engines both emit + // retained layouts through this walk, so containment pins here too. + var nodes: [2]WidgetLayoutNode = undefined; + const layout = try layoutWidgetTree(field, field.frame, &nodes); + var layout_commands: [8]CanvasCommand = undefined; + var layout_builder = Builder.init(&layout_commands); + try layout.emitDisplayList(&layout_builder, .{}); + try expectSingleLinePresentedField(layout_builder.displayList(), field); + + // A search field (the other emitter) contains the same value the + // same way: presented bytes and a forced clip. + var search = field; + search.kind = .search_field; + var search_commands: [32]CanvasCommand = undefined; + var search_builder = Builder.init(&search_commands); + try emitWidgetTree(&search_builder, search, .{}); + const search_list = search_builder.displayList(); + try std.testing.expect(search_list.findCommandById(widgetPartId(7, 7)) != null); + switch (search_list.findCommandById(widgetPartId(7, 9)).?.command) { + .draw_text => |text| try std.testing.expectEqualStrings("one two three", text.text), + else => return error.TestUnexpectedResult, + } + + // A textarea keeps its raw line breaks: it is the one genuinely + // multi-line editable kind. + var textarea = field; + textarea.kind = .textarea; + textarea.frame = geometry.RectF.init(10, 12, 200, 120); + var area_commands: [8]CanvasCommand = undefined; + var area_builder = Builder.init(&area_commands); + try emitWidgetTree(&area_builder, textarea, .{}); + switch (area_builder.displayList().findCommandById(widgetPartId(7, 3)).?.command) { + .draw_text => |text| try std.testing.expectEqualStrings("one\ntwo\r\nthree", text.text), + else => return error.TestUnexpectedResult, + } +} + +fn expectSingleLinePresentedField(display_list: DisplayList, field: Widget) !void { + // The content-rect clip is FORCED for a value holding a line break, + // even though the presented value fits on one line. + const viewport = textInputViewportForWidget(field, .{}).?; + switch (display_list.findCommandById(widgetPartId(field.id, 16)).?.command) { + .push_clip => |clip| try expectRectApprox(viewport, clip.rect), + else => return error.TestUnexpectedResult, + } + switch (display_list.findCommandById(widgetPartId(field.id, 3)).?.command) { + .draw_text => |text| { + // `\n` and each byte of `\r\n` present as spaces — same + // length, so caret/selection offsets address the raw value. + try std.testing.expectEqualStrings("one two three", text.text); + // The presented value lays out as exactly ONE line. + var lines: [4]TextLine = undefined; + const run = try layoutTextRun(text, text.text_layout.?, &lines); + try std.testing.expectEqual(@as(usize, 1), run.lines.len); + // ... and that line's painted bounds sit inside the field. + const bounds = run.lines[0].bounds; + try std.testing.expect(bounds.maxY() <= field.frame.maxY() + 0.001); + try std.testing.expect(bounds.y >= field.frame.y - 0.001); + }, + else => return error.TestUnexpectedResult, + } +} + test "search fields clip an overflowing value and keep chrome outside the clip" { const long_text = "an overflowing search query that runs past the narrow field"; const field = Widget{ diff --git a/src/runtime/canvas_widget_clipboard_tests.zig b/src/runtime/canvas_widget_clipboard_tests.zig index 616932810..1add490c7 100644 --- a/src/runtime/canvas_widget_clipboard_tests.zig +++ b/src/runtime/canvas_widget_clipboard_tests.zig @@ -168,6 +168,83 @@ test "keyboard-only selection (shift+arrows) feeds copy" { try std.testing.expectEqualStrings("ry", try harness.runtime.readClipboard(&clipboard_buffer)); } +test "a multi-line paste into a single-line field strips line breaks; a textarea keeps them" { + var app_state: ClipboardTestApp = .{}; + const app = app_state.app(); + const harness = try createClipboardHarness(app); + defer harness.destroy(std.testing.allocator); + + const children = [_]canvas.Widget{ + .{ + .id = 2, + .kind = .text_field, + .frame = geometry.RectF.init(12, 16, 200, 36), + .text = "", + }, + .{ + .id = 3, + .kind = .textarea, + .frame = geometry.RectF.init(12, 64, 280, 100), + .text = "", + }, + }; + var nodes: [3]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &children }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + // Interior LF, CRLF, and a trailing CR: the single-line field strips + // every break (the HTML value-sanitization rule — lines join with + // nothing between them), and the app-facing keyboard event carries + // the SAME stripped insert so a model mirror stays byte-identical. + try harness.runtime.writeClipboard("alpha\nbeta\r\ngamma\r"); + try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 100, 30)); + try harness.runtime.dispatchPlatformEvent(app, keyInput("v", cmd)); + var retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("alphabetagamma", retained.nodes[1].widget.text); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(14), retained.nodes[1].widget.text_selection.?); + try std.testing.expectEqualStrings("alphabetagamma", app_state.last_edit_insert[0..app_state.last_edit_insert_len]); + try std.testing.expect(!app_state.saw_truncated); + + // The SAME clipboard pasted into the textarea keeps its breaks: only + // single-line kinds sanitize. + try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 100, 100)); + try harness.runtime.dispatchPlatformEvent(app, keyInput("v", cmd)); + retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("alpha\nbeta\r\ngamma\r", retained.nodes[2].widget.text); + try std.testing.expectEqualStrings("alpha\nbeta\r\ngamma\r", app_state.last_edit_insert[0..app_state.last_edit_insert_len]); +} + +test "a paste of only line breaks into a single-line field inserts nothing and keeps the selection" { + var app_state: ClipboardTestApp = .{}; + const app = app_state.app(); + const harness = try createClipboardHarness(app); + defer harness.destroy(std.testing.allocator); + + const text_field = canvas.Widget{ + .id = 2, + .kind = .text_field, + .frame = geometry.RectF.init(12, 16, 200, 36), + .text = "Keep", + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{text_field} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + // Select all, then paste bare newlines: the sanitized insert is + // EMPTY, so the edit suppresses — nothing inserts, the live + // selection survives, and the app hears no insert at all (an empty + // insert is cut's delete-selection edit, which this must never be). + try harness.runtime.writeClipboard("\r\n\n"); + try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 100, 30)); + try harness.runtime.dispatchPlatformEvent(app, keyInput("a", cmd)); + try harness.runtime.dispatchPlatformEvent(app, keyInput("v", cmd)); + const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("Keep", retained.nodes[1].widget.text); + try std.testing.expectEqualDeep(canvas.TextSelection{ .anchor = 0, .focus = 4 }, retained.nodes[1].widget.text_selection.?); + try std.testing.expect(!app_state.saw_empty_insert); + try std.testing.expectEqual(@as(usize, 0), app_state.last_edit_insert_len); +} + test "paste clamps to view text capacity and flags truncation loudly" { var app_state: ClipboardTestApp = .{}; const app = app_state.app(); diff --git a/src/runtime/canvas_widget_text_tests.zig b/src/runtime/canvas_widget_text_tests.zig index 20c2a10d1..f819c988c 100644 --- a/src/runtime/canvas_widget_text_tests.zig +++ b/src/runtime/canvas_widget_text_tests.zig @@ -588,6 +588,271 @@ test "plain Enter inserts a newline in a canvas textarea; chorded Enter never ed try std.testing.expectEqualStrings("First\n", retained.nodes[1].widget.text); } +test "Enter in a single-line input never inserts, even when the host stuffs a newline into the key event" { + const TestApp = struct { + fn app(self: *@This()) App { + return .{ .context = self, .name = "gpu-widget-input-enter", .source = platform.WebViewSource.html("

Hello

") }; + } + }; + + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + var app_state: TestApp = .{}; + const app = app_state.app(); + try harness.start(app); + + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = "canvas", + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 0, 260, 160), + }); + + const input = canvas.Widget{ + .id = 2, + .kind = .input, + .frame = geometry.RectF.init(12, 16, 180, 32), + .text = "Draft", + .semantics = .{ .label = "Title" }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{input} }, geometry.RectF.init(0, 0, 260, 160), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_down, + .x = 100, + .y = 30, + } }); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "a", + .modifiers = .{ .primary = true, .command = true }, + } }); + + // The macOS-host shape: Return as a bare `enter` keydown. Enter in a + // single-line field submits; it is NOT an edit. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "enter", + } }); + var retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("Draft", retained.nodes[1].widget.text); + try std.testing.expectEqualDeep(canvas.TextSelection{ .anchor = 0, .focus = 5 }, retained.nodes[1].widget.text_selection.?); + + // A host that stuffs the newline into the key event's text payload + // still edits nothing: the sanitized insert strips to empty and + // suppresses — the live selection is not deleted. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "enter", + .text = "\n", + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .key_down, + .key = "enter", + .text = "\r", + } }); + retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("Draft", retained.nodes[1].widget.text); + try std.testing.expectEqualDeep(canvas.TextSelection{ .anchor = 0, .focus = 5 }, retained.nodes[1].widget.text_selection.?); +} + +test "automation set_text with line breaks lands sanitized in single-line fields and raw in textareas" { + const TestApp = struct { + fn app(self: *@This()) App { + return .{ .context = self, .name = "gpu-widget-set-text-breaks", .source = platform.WebViewSource.html("

Hello

") }; + } + }; + + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + var app_state: TestApp = .{}; + const app = app_state.app(); + try harness.start(app); + + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = "canvas", + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 0, 320, 220), + }); + + const children = [_]canvas.Widget{ + .{ + .id = 2, + .kind = .search_field, + .frame = geometry.RectF.init(12, 16, 200, 36), + .text = "", + .semantics = .{ .label = "Query" }, + }, + .{ + .id = 3, + .kind = .textarea, + .frame = geometry.RectF.init(12, 64, 280, 100), + .text = "", + .semantics = .{ .label = "Notes" }, + }, + }; + var nodes: [3]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &children }, geometry.RectF.init(0, 0, 320, 220), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + // Automation set_text rides the REAL text-input event path, so its + // line breaks sanitize at the same seam a paste does. + try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 2, .action = .set_text, .value = "edge\ncustomers\r\nfirst" }); + var retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("edgecustomersfirst", retained.nodes[1].widget.text); + + // The textarea takes the same payload verbatim. + try dispatchAutomationWidgetAction(&harness.runtime, app, .{ .view_label = "canvas", .id = 3, .action = .set_text, .value = "edge\ncustomers\r\nfirst" }); + retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("edge\ncustomers\r\nfirst", retained.nodes[2].widget.text); +} + +test "ime composition with a newline sanitizes into single-line fields before commit" { + const TestApp = struct { + fn app(self: *@This()) App { + return .{ .context = self, .name = "gpu-widget-ime-newline", .source = platform.WebViewSource.html("

Hello

") }; + } + }; + + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + var app_state: TestApp = .{}; + const app = app_state.app(); + try harness.start(app); + + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = "canvas", + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 0, 260, 120), + }); + + const text_field = canvas.Widget{ + .id = 2, + .kind = .text_field, + .frame = geometry.RectF.init(12, 16, 180, 36), + .text = "Cafe", + .semantics = .{ .label = "Name" }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{text_field} }, geometry.RectF.init(0, 0, 260, 120), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_down, + .x = 100, + .y = 30, + } }); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id); + + // A composition PREVIEW carrying a newline sanitizes at the same + // seam every insert does — the preview in the retained editor holds + // the stripped bytes, so the COMMIT (which lands whatever the + // preview holds) can never commit a line break. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .ime_set_composition, + .text = " au\nlait", + .composition_cursor = 8, + } }); + var retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("Cafe aulait", retained.nodes[1].widget.text); + try std.testing.expectEqualDeep(canvas.TextRange.init(4, 11), retained.nodes[1].widget.text_composition.?); + + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .ime_commit_composition, + } }); + retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqualStrings("Cafe aulait", retained.nodes[1].widget.text); + try std.testing.expect(retained.nodes[1].widget.text_composition == null); +} + +test "a model-set single-line value with a newline paints one line inside the field" { + const TestApp = struct { + fn app(self: *@This()) App { + return .{ .context = self, .name = "gpu-widget-value-newline", .source = platform.WebViewSource.html("

Hello

") }; + } + }; + + const harness = try TestHarness().create(std.testing.allocator, .{}); + defer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + var app_state: TestApp = .{}; + const app = app_state.app(); + try harness.start(app); + + _ = try harness.runtime.createView(.{ + .window_id = 1, + .label = "canvas", + .kind = .gpu_surface, + .frame = geometry.RectF.init(0, 0, 320, 120), + }); + + // A Zig core can put a newline into a field's VALUE today; the + // sanitize seam only guards EDITS. The retained value keeps the raw + // bytes (semantics and automation report honestly), but the painted + // text presents the breaks as spaces on ONE line, under a forced + // content-rect clip — nothing escapes the rounded border. + const input = canvas.Widget{ + .id = 2, + .kind = .input, + .frame = geometry.RectF.init(12, 16, 200, 32), + .text = "one\ntwo", + .semantics = .{ .label = "Title" }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{input} }, geometry.RectF.init(0, 0, 320, 120), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + _ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{}); + + const display_list = try harness.runtime.canvasDisplayList(1, "canvas"); + var saw_presented_text = false; + var saw_clip = false; + for (display_list.commands) |command| { + switch (command) { + .draw_text => |text| { + if (text.id == testCanvasWidgetPartId(2, 3)) { + try std.testing.expectEqualStrings("one two", text.text); + saw_presented_text = true; + } + }, + .push_clip => |clip| { + if (clip.id == testCanvasWidgetPartId(2, 16)) saw_clip = true; + }, + else => {}, + } + } + try std.testing.expect(saw_presented_text); + try std.testing.expect(saw_clip); + + // Semantics (and therefore automation and assistive tech) still read + // the RAW model value: presentation never rewrites the model. + const snapshot = harness.runtime.automationSnapshot("Widgets"); + try std.testing.expectEqualStrings("one\ntwo", snapshot.widgets[0].text_value); +} + test "runtime applies ime composition edits to canvas text fields" { const TestApp = struct { fn app(self: *@This()) App { diff --git a/src/runtime/session_tests.zig b/src/runtime/session_tests.zig index d4be5f128..3516e80fa 100644 --- a/src/runtime/session_tests.zig +++ b/src/runtime/session_tests.zig @@ -162,6 +162,11 @@ fn sessionView(ui: *SessionApp.Ui, model: *const SessionModel) SessionApp.Ui.Nod .placeholder = "Name", .on_input = SessionApp.Ui.inputMsg(.name_edit), }, .{}), + // A static multi-line source the paste replay pin copies from: + // the journal records only the raw select-all/copy/paste + // platform events, so the multi-line clipboard bytes and the + // sanitized single-line insert both re-derive on replay. + ui.el(.textarea, .{ .text = "line one\nline two" }, .{}), ui.text(.{}, ui.fmt("Query {s} ({d}) Name {s} ({d})", .{ model.queryText(), model.query_edits, model.nameText(), model.name_edits })), ui.button(.{ .on_press = .increment }, "Increment"), }); @@ -337,6 +342,55 @@ fn recordReferenceSession(gpa: std.mem.Allocator, buffer: *JournalBuffer, web_la try std.testing.expectEqualStrings("", app_state.model.queryText()); try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + // A recorded MULTI-LINE PASTE replays to the identical sanitized + // value: copy the textarea's two lines (select-all + copy — raw + // journaled inputs that rebuild the clipboard on replay), then paste + // into the single-line search field. Sanitization is deterministic + // derivation at the apply seam, so the replayed editor, the model + // mirror, and the fingerprint all land on the same stripped bytes. + var textarea_frame: ?geometry.RectF = null; + for ((try harness.runtime.canvasWidgetLayout(1, canvas_label)).nodes) |node| { + if (node.widget.kind == .textarea) textarea_frame = node.frame; + } + const source_frame = textarea_frame.?; + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pointer_down, + .x = source_frame.x + source_frame.width * 0.5, + .y = source_frame.y + source_frame.height * 0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .key_down, + .key = "a", + .modifiers = .{ .primary = true, .command = true }, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .key_down, + .key = "c", + .modifiers = .{ .primary = true, .command = true }, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .pointer_down, + .x = field_frame.x + field_frame.width * 0.5, + .y = field_frame.y + field_frame.height * 0.5, + } }); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = canvas_label, + .kind = .key_down, + .key = "v", + .modifiers = .{ .primary = true, .command = true }, + } }); + try std.testing.expectEqualStrings("line oneline two", app_state.model.queryText()); + try harness.runtime.dispatchPlatformEvent(app, .frame_requested); + recorder.finish(); try std.testing.expect(!recorder.failed); @@ -389,10 +443,11 @@ test "a recorded session replays to identical model state and fingerprints" { try std.testing.expect(recorded.model.stamp_ms != 0); try std.testing.expectEqual(@as(u32, 1), recorded.model.spectrum_count); try std.testing.expect(recorded.model.band_checksum != 0); - // The typed insert and the DERIVED Escape-clear both reached the - // model's `on_input` mirror, and the clear left it empty. - try std.testing.expectEqual(@as(u32, 2), recorded.model.query_edits); - try std.testing.expectEqualStrings("", recorded.model.queryText()); + // The typed insert, the DERIVED Escape-clear, and the sanitized + // multi-line paste all reached the model's `on_input` mirror — the + // paste landing STRIPPED of its line breaks (single-line rule). + try std.testing.expectEqual(@as(u32, 3), recorded.model.query_edits); + try std.testing.expectEqualStrings("line oneline two", recorded.model.queryText()); const replayed = try replayIntoFreshApp(gpa, buffer.journalBytes(), true); try std.testing.expect(replayed.report.ok()); From 48512e8a6b56dab80d1ad696cdb40b4e0d76a58d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 02:34:20 -0500 Subject: [PATCH 4/5] Sanitize clipboard pastes before clamping them to capacity - clampCanvasWidgetPasteText (shared by the cmd+V shortcut and the context-menu Paste) now strips single-line targets' line breaks BEFORE measuring against remaining capacity, so a near-limit paste never spends free bytes on breaks the seam strips anyway ("a\nbc" into 3 free bytes lands "abc", not "ab"), and a paste that sanitizes to nothing is not reported truncated. - Boundary tests pin both paste paths at exactly three free bytes: retained editor and the app's stamped edit hear the identical whole sanitized suffix with no false truncation flag. --- src/runtime/canvas_widget_clipboard_tests.zig | 46 ++++++++++++++++ .../canvas_widget_context_menu_tests.zig | 55 +++++++++++++++++++ src/runtime/canvas_widget_events.zig | 14 +++-- src/runtime/canvas_widget_runtime.zig | 31 ++++++++--- 4 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/runtime/canvas_widget_clipboard_tests.zig b/src/runtime/canvas_widget_clipboard_tests.zig index 1add490c7..f2ce10a44 100644 --- a/src/runtime/canvas_widget_clipboard_tests.zig +++ b/src/runtime/canvas_widget_clipboard_tests.zig @@ -245,6 +245,52 @@ test "a paste of only line breaks into a single-line field inserts nothing and k try std.testing.expectEqual(@as(usize, 0), app_state.last_edit_insert_len); } +test "a near-capacity multi-line paste sanitizes before it clamps" { + var app_state: ClipboardTestApp = .{}; + const app = app_state.app(); + const harness = try createClipboardHarness(app); + defer harness.destroy(std.testing.allocator); + + // Fill the view's shared widget-text storage to EXACTLY three free + // bytes, the boundary where the sanitize/clamp ordering shows: + // pasting "a\nbc" must land "abc" (sanitized 3 bytes, fits whole). + // Clamping the raw clipboard first would spend the third byte on + // the '\n' the seam strips anyway — "a\nb" then "ab" — silently + // discarding the valid 'c' and flagging a truncation that never + // happened to the bytes that count. + const fill_len = canvas_limits.max_canvas_widget_text_bytes_per_view - 3; + const fill = try std.testing.allocator.alloc(u8, fill_len); + defer std.testing.allocator.free(fill); + @memset(fill, 'x'); + const text_field = canvas.Widget{ + .id = 2, + .kind = .text_field, + .frame = geometry.RectF.init(12, 16, 200, 36), + .text = fill, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{text_field} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + // Pin the boundary premise: exactly 3 bytes of capacity remain. + try std.testing.expectEqual(fill_len, harness.runtime.views[0].widget_text_len); + + try harness.runtime.writeClipboard("a\nbc"); + try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 100, 30)); + try harness.runtime.dispatchPlatformEvent(app, keyInput("end", .{})); + try harness.runtime.dispatchPlatformEvent(app, keyInput("v", cmd)); + + // The retained editor holds the whole sanitized suffix ... + const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + const text = retained.nodes[1].widget.text; + try std.testing.expectEqual(fill_len + 3, text.len); + try std.testing.expectEqualStrings("abc", text[text.len - 3 ..]); + try std.testing.expectEqualDeep(canvas.TextSelection.collapsed(text.len), retained.nodes[1].widget.text_selection.?); + // ... the app's stamped edit is byte-identical to what the editor + // applied, and nothing was (falsely) reported truncated. + try std.testing.expectEqualStrings("abc", app_state.last_edit_insert[0..app_state.last_edit_insert_len]); + try std.testing.expect(!app_state.saw_truncated); +} + test "paste clamps to view text capacity and flags truncation loudly" { var app_state: ClipboardTestApp = .{}; const app = app_state.app(); diff --git a/src/runtime/canvas_widget_context_menu_tests.zig b/src/runtime/canvas_widget_context_menu_tests.zig index ec8615674..2a44671a0 100644 --- a/src/runtime/canvas_widget_context_menu_tests.zig +++ b/src/runtime/canvas_widget_context_menu_tests.zig @@ -25,6 +25,9 @@ const MenuTestApp = struct { last_menu_item_index: usize = 0, request_count: u32 = 0, last_request_target: canvas.ObjectId = 0, + last_edit_insert: [64]u8 = undefined, + last_edit_insert_len: usize = 0, + saw_truncated: bool = false, fn app(self: *@This()) App { return .{ .context = self, .name = "context-menus", .source = platform.WebViewSource.html("

Hello

"), .event_fn = event }; @@ -45,6 +48,17 @@ const MenuTestApp = struct { self.request_count += 1; self.last_request_target = request_event.target_id; }, + .canvas_widget_keyboard => |keyboard_event| { + if (keyboard_event.keyboard.edit_truncated) self.saw_truncated = true; + if (keyboard_event.keyboard.edit) |edit| switch (edit) { + .insert_text => |text| { + const len = @min(text.len, self.last_edit_insert.len); + @memcpy(self.last_edit_insert[0..len], text[0..len]); + self.last_edit_insert_len = len; + }, + else => {}, + }; + }, else => {}, } } @@ -210,6 +224,47 @@ test "right click on editable text presents the default edit menu wired to clipb try std.testing.expectEqualDeep(canvas.TextSelection{ .anchor = 0, .focus = 5 }, retained.nodes[1].widget.text_selection.?); } +test "a near-capacity multi-line context-menu paste sanitizes before it clamps" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + // Same boundary as the cmd+V shortcut test: exactly three free bytes + // in the view's shared widget-text storage, clipboard "a\nbc". The + // context menu's Paste flows through the same sanitize-then-clamp + // helper, so the sanitized "abc" lands whole instead of the raw + // clamp's "a\nb" -> "ab". + const fill_len = canvas_limits.max_canvas_widget_text_bytes_per_view - 3; + const fill = try std.testing.allocator.alloc(u8, fill_len); + defer std.testing.allocator.free(fill); + @memset(fill, 'x'); + const text_field = canvas.Widget{ + .id = 2, + .kind = .text_field, + .frame = geometry.RectF.init(12, 16, 200, 36), + .text = fill, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{text_field} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + try std.testing.expectEqual(fill_len, harness.runtime.views[0].widget_text_len); + + try harness.runtime.writeClipboard("a\nbc"); + try harness.runtime.dispatchPlatformEvent(app, rightClick(100, 30)); + // Paste is the default edit menu's third item. + try harness.runtime.dispatchPlatformEvent(app, menuAction(2, 3)); + + // Retained editor and the app's stamped edit hear the same whole + // sanitized suffix, with no false truncation flag. + const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + const text = retained.nodes[1].widget.text; + try std.testing.expectEqual(fill_len + 3, text.len); + try std.testing.expectEqualStrings("abc", text[text.len - 3 ..]); + try std.testing.expectEqualStrings("abc", app_state.last_edit_insert[0..app_state.last_edit_insert_len]); + try std.testing.expect(!app_state.saw_truncated); +} + test "right click on selected static text presents a copy-only menu" { var app_state: MenuTestApp = .{}; const app = app_state.app(); diff --git a/src/runtime/canvas_widget_events.zig b/src/runtime/canvas_widget_events.zig index cc95c333b..b2a305421 100644 --- a/src/runtime/canvas_widget_events.zig +++ b/src/runtime/canvas_widget_events.zig @@ -1796,9 +1796,12 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type { /// - cut: copy, then stamp a delete-selection edit onto the routed /// keyboard event so runtime widget and app model apply the same /// removal. - /// - paste: reads the clipboard into `paste_buffer`, clamps it to - /// the view's text capacity (setting `edit_truncated` loudly), - /// and stamps the insertion onto the routed keyboard event. + /// - paste: reads the clipboard into `paste_buffer`, sanitizes it + /// for the target kind and THEN clamps to the view's text + /// capacity (setting `edit_truncated` loudly) — order matters: + /// clamping first would spend capacity on line-break bytes the + /// seam strips anyway — and stamps the insertion onto the + /// routed keyboard event. /// /// Platforms without a clipboard capability report /// `UnsupportedService`; the shortcut degrades to a no-op instead @@ -1883,7 +1886,10 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type { // `on_input` mirror hear byte-identical sanitized inserts // (clipboard paste from both entry points, typed and // automation text_input, IME composition — every insertion - // source flows through this one seam). A suppressed edit (an + // source flows through this one seam). Pre-stamped pastes + // arrive already sanitized (`clampCanvasWidgetPasteText` + // strips BEFORE clamping so capacity never counts stripped + // bytes); re-sanitizing them is a no-op. A suppressed edit (an // insert that was ONLY line breaks) also clears any raw // pre-stamped paste so the app can never hear bytes the // editor refused; the app-side fallback derivation applies diff --git a/src/runtime/canvas_widget_runtime.zig b/src/runtime/canvas_widget_runtime.zig index 1c2df23f6..7f4c41f3c 100644 --- a/src/runtime/canvas_widget_runtime.zig +++ b/src/runtime/canvas_widget_runtime.zig @@ -393,16 +393,33 @@ pub fn canvasWidgetEditableTextKind(kind: canvas.WidgetKind) bool { return kind == .input or kind == .text_field or kind == .search_field or kind == .combobox or kind == .textarea; } -/// Clipboard paste text clamped to what the view's shared widget text -/// storage can absorb (the bytes the edit replaces are freed first). -/// Clamping lands on a UTF-8 boundary; `truncated` is the loud flag the -/// runtime forwards to apps on the keyboard event. +/// Clipboard paste text sanitized for the target kind and then clamped +/// to what the view's shared widget text storage can absorb (the bytes +/// the edit replaces are freed first). Clamping lands on a UTF-8 +/// boundary; `truncated` is the loud flag the runtime forwards to apps +/// on the keyboard event. pub const CanvasWidgetPasteClamp = struct { text: []const u8 = "", truncated: bool = false, }; pub fn clampCanvasWidgetPasteText(widget: canvas.Widget, view_text_len: usize, text: []const u8) CanvasWidgetPasteClamp { + // Sanitize BEFORE clamping — the shared step for BOTH paste entry + // points (the cmd+V shortcut and the context-menu Paste). A + // single-line target strips \r/\n at the edit-derivation seam + // anyway, so capacity must be measured against the bytes that + // actually land: clamping the raw clipboard first discarded valid + // suffix text near the limit ("a\nbc" into 3 free bytes clamped to + // "a\nb" and then sanitized to "ab", when the sanitized "abc" fits + // whole). The stamped edit is the post-clamp bytes, so the retained + // editor and the app's `on_input` mirror still hear identically — + // the seam's re-sanitize is a no-op on already-stripped text. A + // paste that sanitizes to nothing clamps to nothing; that is the + // sanitize rule speaking, NOT capacity, so `truncated` stays false. + const sanitized = if (canvas.sanitizedSingleLineTextInputEvent(widget.kind, .{ .insert_text = text })) |event| + event.insert_text + else + return .{}; const capacity = @import("canvas_limits.zig").max_canvas_widget_text_bytes_per_view; const replaced_len = blk: { if (widget.text_composition) |composition| break :blk composition.byteLen(widget.text.len); @@ -411,9 +428,9 @@ pub fn clampCanvasWidgetPasteText(widget: canvas.Widget, view_text_len: usize, t }; const used = view_text_len -| replaced_len; const available = capacity -| used; - if (text.len <= available) return .{ .text = text }; - const clamped = canvas.snapTextOffset(text, available); - return .{ .text = text[0..clamped], .truncated = true }; + if (sanitized.len <= available) return .{ .text = sanitized }; + const clamped = canvas.snapTextOffset(sanitized, available); + return .{ .text = sanitized[0..clamped], .truncated = true }; } pub fn canvasWidgetSingleLineTextKind(kind: canvas.WidgetKind) bool { From b4c39bbd342b62d73f972c6d0f227568fca6d13c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 17 Jul 2026 02:54:51 -0500 Subject: [PATCH 5/5] Move emit-built text bytes into the display-list builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The builder contract (pinned at Builder.allocPathElements) lets a display list accumulate across several emit calls or be held while another builder emits; presented single-line values and formatted chart labels lived in thread-local pools RESET at each emit entry, so a second emit overwrote text an earlier list still sliced. - Builder now owns the bytes beside its path-element store: allocChartLabelBytes keeps the chart label budget's loud ChartLabelBytesFull, and allocTextBytes holds presented values under a per-view-text-budget-sized store (lockstep-tested against max_canvas_text_bytes_per_view) with the raw-value fallback intact; both thread-local pools and the per-emit resets are gone. - Regression tests emit twice — into another builder and accumulated into one — and pin both lists' draw_text bytes intact for presented fields and tick labels; both fail against the pool-reset design. --- src/primitives/canvas/chart.zig | 14 ++-- src/primitives/canvas/chart_tests.zig | 66 +++++++++++++++++++ src/primitives/canvas/commands.zig | 48 ++++++++++++++ src/primitives/canvas/root.zig | 1 + .../canvas/widget_builtin_tests.zig | 45 +++++++++++++ src/primitives/canvas/widget_render.zig | 62 ++++++----------- .../canvas/widget_render_controls.zig | 14 ++-- src/primitives/canvas/widget_text_input.zig | 54 ++++++--------- src/runtime/canvas_widget_text_tests.zig | 12 ++++ 9 files changed, 227 insertions(+), 89 deletions(-) diff --git a/src/primitives/canvas/chart.zig b/src/primitives/canvas/chart.zig index 73f6ffb0a..84d719b85 100644 --- a/src/primitives/canvas/chart.zig +++ b/src/primitives/canvas/chart.zig @@ -44,13 +44,13 @@ pub const max_chart_points_per_series: usize = 256; /// filled series — or dozens of sparkline-sized ones — per frame. pub const max_chart_path_elements_per_frame: usize = 2048; -/// Per-frame byte budget for the formatted tick/tooltip label scratch -/// (threadlocal frame-lifetime storage: the emitted `drawText` commands -/// slice into it until the runtime copies the display list). A y axis -/// labels at most a handful of ticks and a -/// hover tooltip a row per series, each `max_chart_value_label_bytes` -/// long, so 2 KiB holds dozens of charts per frame; overflow fails -/// loudly with `ChartLabelBytesFull`. +/// Byte budget for the display-list builder's own formatted tick/tooltip +/// label store (`Builder.allocChartLabelBytes` — builder-owned like the +/// path-element store, so the emitted `drawText` commands keep their +/// label bytes for the builder's lifetime). A y axis labels at most a +/// handful of ticks and a hover tooltip a row per series, each +/// `max_chart_value_label_bytes` long, so 2 KiB holds dozens of charts +/// per frame; overflow fails loudly with `ChartLabelBytesFull`. pub const max_chart_label_bytes_per_frame: usize = 2048; /// Longest formatted value label: the integer digits of f32's full diff --git a/src/primitives/canvas/chart_tests.zig b/src/primitives/canvas/chart_tests.zig index e08fd483c..ac1adf9d9 100644 --- a/src/primitives/canvas/chart_tests.zig +++ b/src/primitives/canvas/chart_tests.zig @@ -436,6 +436,72 @@ test "axis labels draw muted in reserved gutters and thin to fit" { try testing.expect(narrow_plot.maxY() < widget.frame.maxY()); } +test "emitted tick labels survive a later emit into another builder" { + // The builder contract (the `allocPathElements` rule): a display + // list may be HELD while another builder emits, and it may + // accumulate several emit calls — either way every emitted command + // stays intact. Label bytes live in the BUILDER's own store, so a + // second chart's labels can never overwrite a held list's text the + // way a per-emit-reset scratch pool would. + var widget = Widget{ + .id = 93, + .kind = WidgetKind.chart, + .frame = geometry.RectF.init(0, 0, 240, 120), + .chart = .{ + .series = &.{.{ .kind = .line, .values = &[_]f32{ 0.2, 0.8 } }}, + .y_min = 0, + .y_max = 1, + .grid_lines = 1, + .y_labels = true, + }, + }; + const tokens = DesignTokens{}; + var commands: [32]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, widget, tokens); + + // A second chart over a DIFFERENT domain emits different label + // bytes into another builder while the first list is held. + widget.chart.y_min = 0; + widget.chart.y_max = 4; + var other_commands: [32]CanvasCommand = undefined; + var other_builder = Builder.init(&other_commands); + try emitWidgetTree(&other_builder, widget, tokens); + + // The held list still reads the FIRST chart's lattice ("0", "0.5", + // "1"), not the second's ("0", "2", "4") or a byte mix. + try expectChartTickLabels(builder.displayList(), &.{ "0", "0.5", "1" }); + try expectChartTickLabels(other_builder.displayList(), &.{ "0", "2", "4" }); + + // Accumulating both charts into ONE builder keeps both intact too. + var accumulated_commands: [64]CanvasCommand = undefined; + var accumulated = Builder.init(&accumulated_commands); + widget.chart.y_min = 0; + widget.chart.y_max = 1; + try emitWidgetTree(&accumulated, widget, tokens); + var second = widget; + second.id = 94; + second.chart.y_min = 0; + second.chart.y_max = 4; + try emitWidgetTree(&accumulated, second, tokens); + try expectChartTickLabels(accumulated.displayList(), &.{ "0", "0.5", "1", "0", "2", "4" }); +} + +fn expectChartTickLabels(display_list: DisplayList, expected: []const []const u8) !void { + var index: usize = 0; + for (display_list.commands) |command| { + switch (command) { + .draw_text => |text| { + try testing.expect(index < expected.len); + try testing.expectEqualStrings(expected[index], text.text); + index += 1; + }, + else => {}, + } + } + try testing.expectEqual(expected.len, index); +} + test "hover-detail chrome renders only under interaction and is deterministic" { const values = [_]f32{ 0.2, 0.8, 0.4, 0.6 }; const labels = [_][]const u8{ "q1", "q2", "q3", "q4" }; diff --git a/src/primitives/canvas/commands.zig b/src/primitives/canvas/commands.zig index ff4630012..45aa35128 100644 --- a/src/primitives/canvas/commands.zig +++ b/src/primitives/canvas/commands.zig @@ -370,6 +370,13 @@ fn nonNegative(value: f32) f32 { return @max(0, value); } +/// Byte budget for the builder-owned presented-text store +/// (`Builder.text_bytes`). Mirrors the runtime's per-view +/// `max_canvas_text_bytes_per_view` draw-text budget — a lockstep test +/// keeps the two from drifting — so the store can only overflow on a +/// frame the per-view display-list copy would refuse anyway. +pub const max_display_list_text_bytes: usize = 32768; + pub const Builder = struct { commands: []CanvasCommand, len: usize = 0, @@ -387,6 +394,24 @@ pub const Builder = struct { /// anyway. path_elements: [chart_model.max_chart_path_elements_per_frame]drawing_model.PathElement = undefined, path_element_len: usize = 0, + /// Builder-owned storage for text bytes FORMATTED at emit time + /// (chart y-tick labels and hover-detail title/value rows): the + /// `path_elements` lifetime rule applied to label text, so emitted + /// `draw_text` commands survive accumulation across emit calls and + /// other builders' emissions instead of slicing per-emit-reset + /// thread scratch. Overflow fails loudly by the chart label budget's + /// name, exactly as the scratch it replaces did. + label_bytes: [chart_model.max_chart_label_bytes_per_frame]u8 = undefined, + label_byte_len: usize = 0, + /// Builder-owned storage for single-line PRESENTED values (a + /// line-broken value painted with breaks-as-spaces): same lifetime + /// rule again. Sized to the runtime's per-view draw-text budget (a + /// lockstep test keeps the two equal), so a frame that overflows + /// this store was already over the per-view copy budget; the + /// presenting emitter falls back to the raw view-owned value and + /// its forced clip contains the fallback. + text_bytes: [max_display_list_text_bytes]u8 = undefined, + text_byte_len: usize = 0, pub fn init(commands: []CanvasCommand) Builder { return .{ .commands = commands }; @@ -395,6 +420,8 @@ pub const Builder = struct { pub fn reset(self: *Builder) void { self.len = 0; self.path_element_len = 0; + self.label_byte_len = 0; + self.text_byte_len = 0; } /// Reserve `count` path elements in the builder-owned store. The @@ -408,6 +435,27 @@ pub const Builder = struct { return self.path_elements[start..self.path_element_len]; } + /// Persist a formatted chart label into the builder-owned label + /// store (same lifetime contract as `allocPathElements`). + pub fn allocChartLabelBytes(self: *Builder, text: []const u8) error{ChartLabelBytesFull}![]const u8 { + if (self.label_byte_len + text.len > self.label_bytes.len) return error.ChartLabelBytesFull; + const start = self.label_byte_len; + self.label_byte_len += text.len; + @memcpy(self.label_bytes[start..self.label_byte_len], text); + return self.label_bytes[start..self.label_byte_len]; + } + + /// Persist emit-built text bytes (presented single-line values) into + /// the builder-owned text store (same lifetime contract as + /// `allocPathElements`). + pub fn allocTextBytes(self: *Builder, text: []const u8) error{DisplayListTextBytesFull}![]const u8 { + if (self.text_byte_len + text.len > self.text_bytes.len) return error.DisplayListTextBytesFull; + const start = self.text_byte_len; + self.text_byte_len += text.len; + @memcpy(self.text_bytes[start..self.text_byte_len], text); + return self.text_bytes[start..self.text_byte_len]; + } + pub fn displayList(self: *const Builder) DisplayList { return .{ .commands = self.commands[0..self.len] }; } diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index d9b1dc3ad..c4bd25995 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -172,6 +172,7 @@ pub const CommandRef = command_model.CommandRef; pub const DiffKind = command_model.DiffKind; pub const DiffChange = command_model.DiffChange; pub const Builder = command_model.Builder; +pub const max_display_list_text_bytes = command_model.max_display_list_text_bytes; // Canvas render data and cache plans live in `render.zig`; root keeps the public API stable. pub const max_render_state_stack = render_model.max_render_state_stack; diff --git a/src/primitives/canvas/widget_builtin_tests.zig b/src/primitives/canvas/widget_builtin_tests.zig index 3fa58f87b..82f97bb59 100644 --- a/src/primitives/canvas/widget_builtin_tests.zig +++ b/src/primitives/canvas/widget_builtin_tests.zig @@ -4061,6 +4061,51 @@ fn expectSingleLinePresentedField(display_list: DisplayList, field: Widget) !voi } } +test "presented single-line values survive later emits into any builder" { + // The builder contract (the `allocPathElements` rule): a display + // list may be HELD while another builder emits, and one builder may + // ACCUMULATE several emit calls — either way every emitted command + // stays intact. Presented bytes live in the BUILDER's own text + // store, so a later presentation can never overwrite an emitted + // draw_text the way a per-emit-reset scratch pool would (both + // values here present to the same byte length, so a reset pool + // would alias the first list's slice exactly). + const first = Widget{ + .id = 7, + .kind = .input, + .frame = geometry.RectF.init(10, 12, 200, 32), + .text = "one\ntwo", + }; + var second = first; + second.id = 8; + second.text = "AAA\nBBB"; + + // Hold the first builder's list across a second builder's emit. + var commands: [8]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, first, .{}); + var other_commands: [8]CanvasCommand = undefined; + var other_builder = Builder.init(&other_commands); + try emitWidgetTree(&other_builder, second, .{}); + try expectPresentedFieldText(builder.displayList(), 7, "one two"); + try expectPresentedFieldText(other_builder.displayList(), 8, "AAA BBB"); + + // Sequential emits accumulated into ONE builder keep both intact. + var accumulated_commands: [16]CanvasCommand = undefined; + var accumulated = Builder.init(&accumulated_commands); + try emitWidgetTree(&accumulated, first, .{}); + try emitWidgetTree(&accumulated, second, .{}); + try expectPresentedFieldText(accumulated.displayList(), 7, "one two"); + try expectPresentedFieldText(accumulated.displayList(), 8, "AAA BBB"); +} + +fn expectPresentedFieldText(display_list: DisplayList, id: ObjectId, expected: []const u8) !void { + switch (display_list.findCommandById(widgetPartId(id, 3)).?.command) { + .draw_text => |text| try std.testing.expectEqualStrings(expected, text.text), + else => return error.TestUnexpectedResult, + } +} + test "search fields clip an overflowing value and keep chrome outside the clip" { const long_text = "an overflowing search query that runs past the narrow field"; const field = Widget{ diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index d9a2e485b..58999c024 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -113,46 +113,28 @@ pub const sliderWidgetKnobRect = widget_render_controls.sliderWidgetKnobRect; const max_widget_depth: usize = 32; -// Widget-built path elements (`.chart` lines/bands, the `.spinner` arc -// and segments, the `.checkbox` check mark — unlike icons, whose -// elements are comptime-static) allocate from the display-list builder's -// own store (`Builder.allocPathElements`), so the emitted commands' -// element slices share the builder's lifetime instead of pointing into -// shared scratch that a later emission could overwrite. - -/// Frame-lifetime scratch for formatted chart label text (y tick values -/// and hover-detail rows): `drawText` commands slice into it. The event -/// loop is single-threaded and the runtime copies the display list into -/// per-view storage within the same emit call stack, so one threadlocal -/// buffer per frame is sound — reset at each emit entry point. Overflow -/// fails loudly by budget name. -// Lazily heap-allocated per thread (the label pool plus the chart -// polyline scratch below): init on first emit/chart use of a thread — -// the emitting thread by construction — so threads that never emit -// widgets never allocate it. The arrays carry no default and stay -// uninitialized, exactly like the `= undefined` statics they replace. +// Emit-built bytes that emitted commands RETAIN live in the builder, +// never in per-emit-reset scratch: path elements (`.chart` lines/bands, +// the `.spinner` arc and segments, the `.checkbox` check mark — unlike +// icons, whose elements are comptime-static) allocate from +// `Builder.allocPathElements`, formatted chart labels from +// `Builder.allocChartLabelBytes`, and presented single-line values from +// `Builder.allocTextBytes`, so every emitted command's slices share the +// builder's lifetime — a display list accumulated across several emit +// calls, or held while another builder emits, stays intact. + +/// Transient chart scratch, lazily heap-allocated per thread: init on +/// first chart use of a thread — the emitting thread by construction — +/// so threads that never emit charts never allocate it. Purely +/// intra-emit (polyline points are copied into the builder's +/// path-element store before the emitter returns), so nothing emitted +/// retains slices into it. The array carries no default and stays +/// uninitialized, exactly like the `= undefined` static it replaces. const WidgetRenderScratch = struct { - frame_label_bytes: [chart_model.max_chart_label_bytes_per_frame]u8, - frame_label_len: usize = 0, chart_polyline_points: [chart_model.max_chart_points_per_series]geometry.PointF, }; const widget_render_scratch = @import("lazy_tls.zig").LazyTls(WidgetRenderScratch); -fn resetFrameLabelScratch() void { - widget_render_scratch.get().frame_label_len = 0; -} - -/// Persist a formatted label into the frame scratch so the emitted -/// command outlives the local formatting buffer. -fn allocFrameLabelBytes(text: []const u8) Error![]const u8 { - const scratch = widget_render_scratch.get(); - if (scratch.frame_label_len + text.len > scratch.frame_label_bytes.len) return error.ChartLabelBytesFull; - const start = scratch.frame_label_len; - scratch.frame_label_len += text.len; - @memcpy(scratch.frame_label_bytes[start..scratch.frame_label_len], text); - return scratch.frame_label_bytes[start..scratch.frame_label_len]; -} - /// Frame-lifetime scratch (same single-threaded emit contract as the /// label scratch above) holding the root bounds of the tree being /// emitted: the rect a modal surface's scrim covers. Chrome emission @@ -161,8 +143,6 @@ fn allocFrameLabelBytes(text: []const u8) Error![]const u8 { threadlocal var scrim_viewport: ?geometry.RectF = null; pub fn emitWidgetTree(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { - resetFrameLabelScratch(); - widget_text_input.resetWidgetTextInputPresentationPool(); scrim_viewport = widget.frame.normalized(); try emitWidgetDepth(builder, widget, tokens, 0); } @@ -172,8 +152,6 @@ pub fn emitWidgetLayout(builder: *Builder, layout: anytype, tokens: DesignTokens } pub fn emitWidgetLayoutWithState(builder: *Builder, layout: anytype, tokens: DesignTokens, state: WidgetRenderState) Error!void { - resetFrameLabelScratch(); - widget_text_input.resetWidgetTextInputPresentationPool(); scrim_viewport = widgetLayoutRootBounds(layout); try emitWidgetLayoutChildren(builder, layout, null, tokens, state); try emitWidgetLayoutAnchored(builder, layout, tokens, state); @@ -1832,7 +1810,7 @@ fn emitChartAxisLabels(builder: *Builder, widget: Widget, tokens: DesignTokens, for (0..lattice.count) |ordinal| { const value = lattice.value(ordinal); const formatted = chart_model.formatChartValue(&buffer, value, lattice.decimals); - const text = try allocFrameLabelBytes(formatted); + const text = try builder.allocChartLabelBytes(formatted); const width = measureTextWidthForFont(tokens.text_measure, tokens.typography.font_id, text, size); const line_y = chartMapY(value, domain, plot, 0); const top = std.math.clamp(line_y - line_height * 0.5, content.y, @max(content.y, content.maxY() - line_height)); @@ -2336,7 +2314,7 @@ fn emitChartHoverDetail(builder: *Builder, widget: Widget, tokens: DesignTokens, const line_height = size * 1.25; var title_buffer: [chart_model.max_chart_value_label_bytes]u8 = undefined; const title = chartHoverDetailTitle(data, detail.index, &title_buffer); - const title_text = try allocFrameLabelBytes(title); + const title_text = try builder.allocChartLabelBytes(title); var row_y = card.y + chart_detail_pad_v; try builder.drawText(.{ .id = chartCommandId(widget.id, chart_hover_seed, 0, 4), @@ -2366,7 +2344,7 @@ fn emitChartHoverDetail(builder: *Builder, widget: Widget, tokens: DesignTokens, .text = chartDetailRowName(series), }); var value_buffer: [chart_model.max_chart_value_label_bytes]u8 = undefined; - const value_text = try allocFrameLabelBytes(chart_model.formatChartValue(&value_buffer, series.values[detail.index], decimals)); + const value_text = try builder.allocChartLabelBytes(chart_model.formatChartValue(&value_buffer, series.values[detail.index], decimals)); const value_width = measureTextWidthForFont(tokens.text_measure, tokens.typography.font_id, value_text, size); try builder.drawText(.{ .id = chartCommandId(widget.id, chart_hover_row_seed, series_index, 2), diff --git a/src/primitives/canvas/widget_render_controls.zig b/src/primitives/canvas/widget_render_controls.zig index 332635c48..8dcb0c23a 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -465,9 +465,10 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text); var draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); // A presented value (single-line kind holding a line break) lives in - // shared scratch; persist it so this widget's emitted commands - // survive the rest of the walk. No-op for the ordinary raw value. - draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(widget.text, draw_text.text); + // shared scratch; persist it into the builder so this widget's + // emitted commands survive the rest of the walk AND any later emit + // while this display list is held. No-op for the ordinary raw value. + draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(builder, widget.text, draw_text.text); const selection_range = widgetTextSelectionRange(widget); const composition_range = widgetTextCompositionRange(widget); const has_text_affordances = selection_range != null or composition_range != null; @@ -573,9 +574,10 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo const composition_range = widgetTextCompositionRange(widget); const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text); var draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options); - // Presented values persist out of the shared scratch — the - // text-field emitter's rule (no-op for ordinary raw values). - draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(widget.text, draw_text.text); + // Presented values persist out of the shared scratch into the + // builder — the text-field emitter's rule (no-op for ordinary raw + // values). + draw_text.text = widget_text_input.persistWidgetTextInputPresentedText(builder, widget.text, draw_text.text); // Same overflow contract as the text-field emitter: clip only once // the value (or placeholder) overflows the content rect, so the // horizontally scrolled text and its affordances cut at the border. diff --git a/src/primitives/canvas/widget_text_input.zig b/src/primitives/canvas/widget_text_input.zig index 4faf944fd..290523740 100644 --- a/src/primitives/canvas/widget_text_input.zig +++ b/src/primitives/canvas/widget_text_input.zig @@ -8,6 +8,7 @@ const widget_access = @import("widget_access.zig"); const widget_metrics = @import("widget_metrics.zig"); const FontId = @import("root.zig").FontId; +const Builder = @import("commands.zig").Builder; const Color = drawing_model.Color; const DrawText = text_model.DrawText; const TextWrap = text_model.TextWrap; @@ -79,34 +80,20 @@ pub fn widgetTextInputTextNeedsPresentation(widget: Widget) bool { /// Presentation scratch, lazily heap-allocated per thread (the /// `lazy_tls` pattern all canvas scratch uses; the event loop is -/// single-threaded): -/// - `slot` holds ONE presented text at a time — the transient buffer -/// the geometry seams (caret, selection, hit-test, scroll measures) -/// substitute into. Every entry point re-derives it, and within one -/// widget's computation repeated derivations write identical bytes, -/// so the aliasing is harmless. -/// - `pool` appends presented texts for the RENDER walk: emitted -/// `drawText` commands slice into it and must survive until the -/// runtime copies the display list (same emit call stack — the -/// chart-label scratch precedent). Reset at each emit entry point. -/// Sized to the runtime's per-view budgets: `slot` to the widget-text -/// budget (65536), `pool` to the per-frame text budget (32768) — a frame -/// retaining more text fails its own budget first. Overflow falls back -/// to the RAW bytes; the forced clip above keeps even that fallback -/// inside the field's border. +/// single-threaded): `slot` holds ONE presented text at a time — the +/// transient buffer the geometry seams (caret, selection, hit-test, +/// scroll measures) substitute into. Every entry point re-derives it, +/// and within one widget's computation repeated derivations write +/// identical bytes, so the aliasing is harmless. Sized to the runtime's +/// per-view widget-text budget (65536). Nothing EMITTED may retain a +/// slice into it: render emitters persist presented bytes into the +/// display-list builder via `persistWidgetTextInputPresentedText`, whose +/// storage lives exactly as long as the emitted commands do. const WidgetTextPresentationScratch = struct { slot: [65536]u8, - pool: [32768]u8, - pool_len: usize = 0, }; const widget_text_presentation_scratch = @import("lazy_tls.zig").LazyTls(WidgetTextPresentationScratch); -/// Reset the render walk's presentation pool — called by the display-list -/// emit entry points, next to the chart-label scratch reset. -pub fn resetWidgetTextInputPresentationPool() void { - widget_text_presentation_scratch.get().pool_len = 0; -} - /// The text a single-line field lays out, measures, and paints: the raw /// value with `\n`/`\r` presented as spaces (same byte length). Returns /// the raw slice untouched when nothing needs presenting — the @@ -126,19 +113,18 @@ fn presentedSingleLineText(text: []const u8) []const u8 { return scratch.slot[0..text.len]; } -/// Persist presented bytes into the render walk's pool so an emitted +/// Persist presented bytes into the display-list BUILDER so an emitted /// command outlives the shared slot (a later widget's presentation -/// overwrites it). Falls back to the RAW value — stable, view-owned -/// storage — when the pool is full; the forced clip contains that -/// fallback inside the field's border. -pub fn persistWidgetTextInputPresentedText(raw: []const u8, presented: []const u8) []const u8 { +/// overwrites it) — builder-owned storage, because the builder contract +/// lets a display list accumulate across several emit calls or be held +/// while another builder emits, and the emitted `draw_text` must stay +/// intact through both. Falls back to the RAW value — stable, +/// view-owned storage — when the builder's text store is full (only +/// possible on a frame already over the per-view draw-text budget); the +/// forced clip contains that fallback inside the field's border. +pub fn persistWidgetTextInputPresentedText(builder: *Builder, raw: []const u8, presented: []const u8) []const u8 { if (presented.ptr == raw.ptr) return raw; - const scratch = widget_text_presentation_scratch.get(); - if (scratch.pool_len + presented.len > scratch.pool.len) return raw; - const start = scratch.pool_len; - scratch.pool_len += presented.len; - @memcpy(scratch.pool[start..scratch.pool_len], presented); - return scratch.pool[start..scratch.pool_len]; + return builder.allocTextBytes(presented) catch raw; } pub fn textSelectionForWidgetPoint(widget: Widget, point: geometry.PointF, anchor: ?usize, tokens: DesignTokens) ?TextSelection { diff --git a/src/runtime/canvas_widget_text_tests.zig b/src/runtime/canvas_widget_text_tests.zig index f819c988c..b913748e4 100644 --- a/src/runtime/canvas_widget_text_tests.zig +++ b/src/runtime/canvas_widget_text_tests.zig @@ -65,6 +65,18 @@ const builtinBridgeErrorMessage = support.builtinBridgeErrorMessage; const testViewByLabel = support.testViewByLabel; const testCanvasWidgetPartId = support.testCanvasWidgetPartId; +test "the builder's presented-text store mirrors the per-view draw-text budget" { + // The chart path-element lockstep's sibling: presented single-line + // bytes persist into `Builder.text_bytes`, and the store can only + // overflow (falling back to raw bytes under the forced clip) on a + // frame the runtime's per-view display-list copy would refuse + // anyway. Keep the two budgets from drifting. + try std.testing.expectEqual( + @import("canvas_limits.zig").max_canvas_text_bytes_per_view, + canvas.max_display_list_text_bytes, + ); +} + test "runtime exposes retained canvas widget text geometry" { const TestApp = struct { fn app(self: *@This()) App {