Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/single-line-paste-newlines.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 7 additions & 7 deletions src/primitives/canvas/chart.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions src/primitives/canvas/chart_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
48 changes: 48 additions & 0 deletions src/primitives/canvas/commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
Expand All @@ -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
Expand All @@ -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] };
}
Expand Down
161 changes: 161 additions & 0 deletions src/primitives/canvas/events.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input>` — 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
Expand Down Expand Up @@ -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 <input> 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);
}
3 changes: 3 additions & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading