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
1 change: 1 addition & 0 deletions changelog.d/single-line-field-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix: **Single-line fields handle overflowing values**: text, selection rects, composition underlines, and the caret now clip to the field's content rect, and a horizontal scroll offset keeps the caret visible — typing past the edge scrolls the value, Home scrolls back, and deleting never leaves trailing emptiness. Covers text fields, inputs, search fields, and comboboxes; values that fit render exactly as before.
3 changes: 3 additions & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,9 @@ pub const textInputClearButtonHitRect = widget_runtime.textInputClearButtonHitRe
pub const textInputContentExtentForWidget = widget_runtime.textInputContentExtentForWidget;
pub const textInputMaxScrollOffsetForWidget = widget_runtime.textInputMaxScrollOffsetForWidget;
pub const clampedTextInputScrollOffsetForWidget = widget_runtime.clampedTextInputScrollOffsetForWidget;
pub const textInputMaxHorizontalScrollOffsetForWidget = widget_runtime.textInputMaxHorizontalScrollOffsetForWidget;
pub const clampedTextInputHorizontalScrollOffsetForWidget = widget_runtime.clampedTextInputHorizontalScrollOffsetForWidget;
pub const textInputCaretVisibleScrollOffsetForWidget = widget_runtime.textInputCaretVisibleScrollOffsetForWidget;
pub const intrinsicWidgetSize = widget_runtime.intrinsicWidgetSize;
pub const cursorForWidgetHit = widget_runtime.cursorForWidgetHit;
pub const cursorForWidgetTarget = widget_runtime.cursorForWidgetTarget;
Expand Down
3 changes: 3 additions & 0 deletions src/primitives/canvas/test_support.zig
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ pub const textInputViewportForWidget = canvas.textInputViewportForWidget;
pub const textInputContentExtentForWidget = canvas.textInputContentExtentForWidget;
pub const textInputMaxScrollOffsetForWidget = canvas.textInputMaxScrollOffsetForWidget;
pub const clampedTextInputScrollOffsetForWidget = canvas.clampedTextInputScrollOffsetForWidget;
pub const textInputMaxHorizontalScrollOffsetForWidget = canvas.textInputMaxHorizontalScrollOffsetForWidget;
pub const clampedTextInputHorizontalScrollOffsetForWidget = canvas.clampedTextInputHorizontalScrollOffsetForWidget;
pub const textInputCaretVisibleScrollOffsetForWidget = canvas.textInputCaretVisibleScrollOffsetForWidget;
pub const intrinsicWidgetSize = canvas.intrinsicWidgetSize;
pub const cursorForWidgetHit = canvas.cursorForWidgetHit;
pub const cursorForWidgetTarget = canvas.cursorForWidgetTarget;
Expand Down
244 changes: 244 additions & 0 deletions src/primitives/canvas/widget_builtin_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3350,3 +3350,247 @@ test "hairline borders snap to whole device columns with smooth arcs" {
const corner = surface.pixelRgba8(5, 4)[3];
try std.testing.expect(corner > 0 and corner < 255);
}

test "single-line fields clip and horizontally scroll an overflowing value" {
const long_text = "a value far too long for a narrow single-line field to show at once";
const field = Widget{
.id = 7,
.kind = .text_field,
.frame = geometry.RectF.init(10, 12, 120, 32),
.text = long_text,
.text_selection = TextSelection.collapsed(long_text.len),
.state = .{ .focused = true },
.semantics = .{ .label = "Name" },
// The retained offset channel: an oversized write clamps to the
// farthest the value can scroll, exactly like the textarea's
// vertical offset does.
.value = 100000,
};
const max_offset = support.textInputMaxHorizontalScrollOffsetForWidget(field, .{});
try std.testing.expect(max_offset > 0);
try std.testing.expectEqual(max_offset, support.clampedTextInputHorizontalScrollOffsetForWidget(field, .{}, 100000));

var zero_offset_field = field;
zero_offset_field.value = 0;

var commands: [8]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try emitWidgetTree(&builder, field, .{});
const display_list = builder.displayList();
var zero_commands: [8]CanvasCommand = undefined;
var zero_builder = Builder.init(&zero_commands);
try emitWidgetTree(&zero_builder, zero_offset_field, .{});
const zero_display_list = zero_builder.displayList();

// Fill, border, offset focus ring, clip, text, caret, pop.
try std.testing.expectEqual(@as(usize, 7), display_list.commandCount());
const viewport = textInputViewportForWidget(field, .{}).?;
switch (display_list.commands[3]) {
.push_clip => |clip| {
try std.testing.expectEqual(widgetPartId(7, 16), clip.id);
try expectRectApprox(viewport, clip.rect);
},
else => return error.TestUnexpectedResult,
}
// The draw-text origin shifts left by exactly the clamped offset.
const scrolled_origin = switch (display_list.commands[4]) {
.draw_text => |text| text.origin,
else => return error.TestUnexpectedResult,
};
const resting_origin = switch (zero_display_list.commands[4]) {
.draw_text => |text| text.origin,
else => return error.TestUnexpectedResult,
};
try std.testing.expectApproxEqAbs(resting_origin.x - max_offset, scrolled_origin.x, 0.001);
try std.testing.expectEqual(resting_origin.y, scrolled_origin.y);
// The end-of-value caret rides the same origin, landing inside the
// clip instead of past the field's border.
switch (display_list.commands[5]) {
.fill_rect => |caret| {
try std.testing.expect(caret.rect.x >= viewport.x - 0.001);
try std.testing.expect(caret.rect.maxX() <= viewport.maxX() + 0.001);
},
else => return error.TestUnexpectedResult,
}
try std.testing.expectEqual(CanvasCommand.pop_clip, display_list.commands[6]);
}

test "the caret keep-visible offset scrolls to the caret and returns home" {
const long_text = "a value far too long for a narrow single-line field to show at once";
var field = Widget{
.id = 7,
.kind = .text_field,
.frame = geometry.RectF.init(10, 12, 120, 32),
.text = long_text,
.text_selection = TextSelection.collapsed(long_text.len),
.state = .{ .focused = true },
.semantics = .{ .label = "Name" },
};
const max_offset = support.textInputMaxHorizontalScrollOffsetForWidget(field, .{});
try std.testing.expect(max_offset > 0);

// Caret at the end, unscrolled: the recompute scrolls forward far
// enough that the caret sits inside the visible span.
const end_offset = support.textInputCaretVisibleScrollOffsetForWidget(field, .{}, 0);
try std.testing.expect(end_offset > 0);
try std.testing.expect(end_offset <= max_offset + 0.001);
field.value = end_offset;
const viewport = textInputViewportForWidget(field, .{}).?;
const end_geometry = textGeometryForWidget(field, .{});
const end_caret = end_geometry.caret_bounds.?;
try std.testing.expect(end_caret.x >= viewport.x - 0.001);
try std.testing.expect(end_caret.maxX() <= viewport.maxX() + 0.001);

// Home: caret back at byte zero scrolls all the way back — the field
// never shows trailing emptiness while text could fill it.
field.text_selection = TextSelection.collapsed(0);
try std.testing.expectEqual(@as(f32, 0), support.textInputCaretVisibleScrollOffsetForWidget(field, .{}, end_offset));

// A value that fits never scrolls and never adjusts.
var short = field;
short.text = "short";
short.text_selection = TextSelection.collapsed(5);
try std.testing.expectEqual(@as(f32, 0), support.textInputMaxHorizontalScrollOffsetForWidget(short, .{}));
try std.testing.expectEqual(@as(f32, 0), support.textInputCaretVisibleScrollOffsetForWidget(short, .{}, 25));
}

test "scrolled single-line selection rects shift with the text origin" {
const long_text = "a value far too long for a narrow single-line field to show at once";
const scrolled = Widget{
.id = 7,
.kind = .text_field,
.frame = geometry.RectF.init(10, 12, 120, 32),
.text = long_text,
.text_selection = .{ .anchor = 2, .focus = 9 },
.state = .{ .focused = true },
.semantics = .{ .label = "Name" },
.value = 20,
};
var resting = scrolled;
resting.value = 0;

var commands: [10]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try emitWidgetTree(&builder, scrolled, .{});
const display_list = builder.displayList();
var resting_commands: [10]CanvasCommand = undefined;
var resting_builder = Builder.init(&resting_commands);
try emitWidgetTree(&resting_builder, resting, .{});
const resting_display_list = resting_builder.displayList();

// Fill, border, focus ring, clip, selection rect, text, selected
// glyphs, pop — the same shape at both offsets.
try std.testing.expectEqual(display_list.commandCount(), resting_display_list.commandCount());
const scrolled_selection = switch (display_list.commands[4]) {
.fill_rect => |rect| rect.rect,
else => return error.TestUnexpectedResult,
};
const resting_selection = switch (resting_display_list.commands[4]) {
.fill_rect => |rect| rect.rect,
else => return error.TestUnexpectedResult,
};
const scrolled_text = switch (display_list.commands[5]) {
.draw_text => |text| text.origin,
else => return error.TestUnexpectedResult,
};
const resting_text = switch (resting_display_list.commands[5]) {
.draw_text => |text| text.origin,
else => return error.TestUnexpectedResult,
};
// Selection geometry and the draw-text origin move together, by
// exactly the scroll offset.
try std.testing.expectApproxEqAbs(@as(f32, 20), resting_selection.x - scrolled_selection.x, 0.001);
try std.testing.expectApproxEqAbs(@as(f32, 20), resting_text.x - scrolled_text.x, 0.001);
try std.testing.expectApproxEqAbs(resting_selection.width, scrolled_selection.width, 0.001);
}

test "short single-line values emit no clip and an unshifted origin" {
const field = Widget{
.id = 7,
.kind = .text_field,
.frame = geometry.RectF.init(10, 12, 120, 32),
.text = "short",
.text_selection = TextSelection.collapsed(5),
.state = .{ .focused = true },
.semantics = .{ .label = "Name" },
// A stale offset on a value that fits clamps to zero: fitting
// fields render exactly as they did before fields scrolled.
.value = 40,
};
var commands: [8]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try emitWidgetTree(&builder, field, .{});
const display_list = builder.displayList();
// Fill, border, offset focus ring, text, caret — no clip pair.
try std.testing.expectEqual(@as(usize, 5), display_list.commandCount());
for (display_list.commands) |command| {
try std.testing.expect(command != .push_clip and command != .pop_clip);
}
var zero = field;
zero.value = 0;
var zero_commands: [8]CanvasCommand = undefined;
var zero_builder = Builder.init(&zero_commands);
try emitWidgetTree(&zero_builder, zero, .{});
const zero_display_list = zero_builder.displayList();
switch (display_list.commands[3]) {
.draw_text => |text| {
try std.testing.expectEqual(switch (zero_display_list.commands[3]) {
.draw_text => |zero_text| zero_text.origin.x,
else => return error.TestUnexpectedResult,
}, text.origin.x);
},
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{
.id = 9,
.kind = .search_field,
.frame = geometry.RectF.init(10, 12, 140, 32),
.text = long_text,
.text_selection = TextSelection.collapsed(long_text.len),
.state = .{ .focused = true },
.semantics = .{ .label = "Search" },
.value = 100000,
};
try std.testing.expect(support.textInputMaxHorizontalScrollOffsetForWidget(field, .{}) > 0);

var commands: [24]CanvasCommand = undefined;
var builder = Builder.init(&commands);
try emitWidgetTree(&builder, field, .{});
const display_list = builder.displayList();

var clip_index: ?usize = null;
var pop_index: ?usize = null;
var text_index: ?usize = null;
var caret_index: ?usize = null;
var clear_transform_index: ?usize = null;
for (display_list.commands, 0..) |command, index| {
switch (command) {
.push_clip => |clip| {
try std.testing.expectEqual(widgetPartId(9, 7), clip.id);
try expectRectApprox(textInputViewportForWidget(field, .{}).?, clip.rect);
clip_index = index;
},
.pop_clip => pop_index = index,
.draw_text => |text| {
if (text.id == widgetPartId(9, 9)) text_index = index;
},
.fill_rect => |caret| {
if (caret.id == widgetPartId(9, 11)) caret_index = index;
},
.transform => {
if (clear_transform_index == null and pop_index != null) clear_transform_index = index;
},
else => {},
}
}
// Text and caret sit inside the clip pair; the trailing clear
// affordance draws after the pop, outside it.
try std.testing.expect(clip_index.? < text_index.?);
try std.testing.expect(text_index.? < caret_index.?);
try std.testing.expect(caret_index.? < pop_index.?);
try std.testing.expect(pop_index.? < clear_transform_index.?);
}
19 changes: 18 additions & 1 deletion src/primitives/canvas/widget_render_controls.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const widgetTextInputOrigin = widget_text_input.widgetTextInputOrigin;
const widgetTextInputClipRect = widget_text_input.widgetTextInputClipRect;
const widgetTextInputDrawText = widget_text_input.widgetTextInputDrawText;
const widgetTextInputInset = widget_text_input.widgetTextInputInset;
const widgetTextInputClipsText = widget_text_input.widgetTextInputClipsText;
const textInputClearButtonRect = widget_text_input.textInputClearButtonRect;
const widgetButtonTextSize = widget_metrics.widgetButtonTextSize;
const widgetBodyTextSize = widget_metrics.widgetBodyTextSize;
Expand Down Expand Up @@ -466,7 +467,12 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke
const selection_range = widgetTextSelectionRange(widget);
const composition_range = widgetTextCompositionRange(widget);
const has_text_affordances = selection_range != null or composition_range != null;
const clips_text = widget.kind == .textarea;
// Textareas always clip (their overflow scrolls vertically); a
// single-line field clips once its value overflows the content rect,
// so the horizontally scrolled text, selection rects, composition
// underline, and caret all cut at the field's border instead of
// painting past it. Short values emit no clip — unchanged emission.
const clips_text = widgetTextInputClipsText(widget, tokens, text_size, text_inset, layout_options);

try builder.fillRoundedRect(.{
.id = widgetPartId(widget.id, 1),
Expand Down Expand Up @@ -555,11 +561,20 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
const icon_size = @max(8, text_size - 2);
const text_inset = widgetTextInputInset(widget, tokens);
const layout_options = widgetTextInputLayoutOptions(widget, tokens, text_size, text_inset);
const clip_rect = widgetTextInputClipRect(widget, tokens, text_size, text_inset, layout_options);
const origin = widgetTextInputOrigin(widget, tokens, text_size, text_inset, layout_options);
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);
// 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.
// The leading glass, trailing chevron, and clear affordance draw
// outside the clip — they are chrome, not scrolling content. Slot 7
// is clear of the field chrome (1..6, 8..13), the focus ring (14),
// and the clear affordance's shape range (15..).
const clips_text = widgetTextInputClipsText(widget, tokens, text_size, text_inset, layout_options);

try builder.fillRoundedRect(.{
.id = widgetPartId(widget.id, 1),
Expand All @@ -578,6 +593,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 14, widget.frame, radius);
try emitSearchFieldIcon(builder, widget, tokens, icon_size);
if (clips_text) try builder.pushClip(.{ .id = widgetPartId(widget.id, 7), .rect = clip_rect, .radius = radius });
if (selection_range) |range| {
if (!range.isCollapsed(widget.text.len)) {
try emitWidgetTextSelectionRects(builder, widget, draw_text, layout_options, range, 8, 0, 1, tokens);
Expand Down Expand Up @@ -609,6 +625,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
}
}
}
if (clips_text) try builder.popClip();
if (widget.kind == .combobox) {
try emitComboboxChevron(builder, widget, tokens, visual);
}
Expand Down
3 changes: 3 additions & 0 deletions src/primitives/canvas/widget_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub const textInputClearButtonHitRect = widget_text_input.textInputClearButtonHi
pub const textInputContentExtentForWidget = widget_text_input.textInputContentExtentForWidget;
pub const textInputMaxScrollOffsetForWidget = widget_text_input.textInputMaxScrollOffsetForWidget;
pub const clampedTextInputScrollOffsetForWidget = widget_text_input.clampedTextInputScrollOffsetForWidget;
pub const textInputMaxHorizontalScrollOffsetForWidget = widget_text_input.textInputMaxHorizontalScrollOffsetForWidget;
pub const clampedTextInputHorizontalScrollOffsetForWidget = widget_text_input.clampedTextInputHorizontalScrollOffsetForWidget;
pub const textInputCaretVisibleScrollOffsetForWidget = widget_text_input.textInputCaretVisibleScrollOffsetForWidget;
pub const textGeometryForWidget = widget_text_input.textGeometryForWidget;

pub const WidgetLayoutTree = struct {
Expand Down
Loading
Loading