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/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/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..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; @@ -566,6 +567,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/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..82f97bb59 100644 --- a/src/primitives/canvas/widget_builtin_tests.zig +++ b/src/primitives/canvas/widget_builtin_tests.zig @@ -3977,6 +3977,135 @@ 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 "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 30b0e17e1..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,7 +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(); scrim_viewport = widget.frame.normalized(); try emitWidgetDepth(builder, widget, tokens, 0); } @@ -171,7 +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(); scrim_viewport = widgetLayoutRootBounds(layout); try emitWidgetLayoutChildren(builder, layout, null, tokens, state); try emitWidgetLayoutAnchored(builder, layout, tokens, state); @@ -1830,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)); @@ -2334,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), @@ -2364,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 975c8abc6..8dcb0c23a 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -463,7 +463,12 @@ 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 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; @@ -497,7 +502,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 +573,11 @@ 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 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. @@ -600,7 +611,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..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; @@ -36,6 +37,96 @@ 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. 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, +}; +const widget_text_presentation_scratch = @import("lazy_tls.zig").LazyTls(WidgetTextPresentationScratch); + +/// 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 display-list BUILDER so an emitted +/// command outlives the shared slot (a later widget's presentation +/// 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; + return builder.allocTextBytes(presented) catch raw; +} + 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 +206,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 +314,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 +334,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 +360,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 +390,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, }; } diff --git a/src/runtime/canvas_widget_clipboard_tests.zig b/src/runtime/canvas_widget_clipboard_tests.zig index 616932810..f2ce10a44 100644 --- a/src/runtime/canvas_widget_clipboard_tests.zig +++ b/src/runtime/canvas_widget_clipboard_tests.zig @@ -168,6 +168,129 @@ 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 "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("