Skip to content

Commit 908e3de

Browse files
ctatekvnwdev
andauthored
Scroll and clip overflowing single-line text inputs (#90)
- Single-line fields clip their text, selection, and caret to the content rect when the value overflows, matching the textarea treatment - A retained horizontal offset keeps the caret in view: edits, programmatic set-text, pointer-placed carets, and field resizes all re-run ensure-visible, and the offset rides the existing retained value channel so replay and schema pins are untouched Co-authored-by: kvnwdev <47703820+kvnwdev@users.noreply.github.com>
1 parent e71338f commit 908e3de

12 files changed

Lines changed: 519 additions & 9 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/primitives/canvas/root.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,9 @@ pub const textInputClearButtonHitRect = widget_runtime.textInputClearButtonHitRe
631631
pub const textInputContentExtentForWidget = widget_runtime.textInputContentExtentForWidget;
632632
pub const textInputMaxScrollOffsetForWidget = widget_runtime.textInputMaxScrollOffsetForWidget;
633633
pub const clampedTextInputScrollOffsetForWidget = widget_runtime.clampedTextInputScrollOffsetForWidget;
634+
pub const textInputMaxHorizontalScrollOffsetForWidget = widget_runtime.textInputMaxHorizontalScrollOffsetForWidget;
635+
pub const clampedTextInputHorizontalScrollOffsetForWidget = widget_runtime.clampedTextInputHorizontalScrollOffsetForWidget;
636+
pub const textInputCaretVisibleScrollOffsetForWidget = widget_runtime.textInputCaretVisibleScrollOffsetForWidget;
634637
pub const intrinsicWidgetSize = widget_runtime.intrinsicWidgetSize;
635638
pub const cursorForWidgetHit = widget_runtime.cursorForWidgetHit;
636639
pub const cursorForWidgetTarget = widget_runtime.cursorForWidgetTarget;

src/primitives/canvas/test_support.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@ pub const textInputViewportForWidget = canvas.textInputViewportForWidget;
314314
pub const textInputContentExtentForWidget = canvas.textInputContentExtentForWidget;
315315
pub const textInputMaxScrollOffsetForWidget = canvas.textInputMaxScrollOffsetForWidget;
316316
pub const clampedTextInputScrollOffsetForWidget = canvas.clampedTextInputScrollOffsetForWidget;
317+
pub const textInputMaxHorizontalScrollOffsetForWidget = canvas.textInputMaxHorizontalScrollOffsetForWidget;
318+
pub const clampedTextInputHorizontalScrollOffsetForWidget = canvas.clampedTextInputHorizontalScrollOffsetForWidget;
319+
pub const textInputCaretVisibleScrollOffsetForWidget = canvas.textInputCaretVisibleScrollOffsetForWidget;
317320
pub const intrinsicWidgetSize = canvas.intrinsicWidgetSize;
318321
pub const cursorForWidgetHit = canvas.cursorForWidgetHit;
319322
pub const cursorForWidgetTarget = canvas.cursorForWidgetTarget;

src/primitives/canvas/widget_builtin_tests.zig

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3350,3 +3350,247 @@ test "hairline borders snap to whole device columns with smooth arcs" {
33503350
const corner = surface.pixelRgba8(5, 4)[3];
33513351
try std.testing.expect(corner > 0 and corner < 255);
33523352
}
3353+
3354+
test "single-line fields clip and horizontally scroll an overflowing value" {
3355+
const long_text = "a value far too long for a narrow single-line field to show at once";
3356+
const field = Widget{
3357+
.id = 7,
3358+
.kind = .text_field,
3359+
.frame = geometry.RectF.init(10, 12, 120, 32),
3360+
.text = long_text,
3361+
.text_selection = TextSelection.collapsed(long_text.len),
3362+
.state = .{ .focused = true },
3363+
.semantics = .{ .label = "Name" },
3364+
// The retained offset channel: an oversized write clamps to the
3365+
// farthest the value can scroll, exactly like the textarea's
3366+
// vertical offset does.
3367+
.value = 100000,
3368+
};
3369+
const max_offset = support.textInputMaxHorizontalScrollOffsetForWidget(field, .{});
3370+
try std.testing.expect(max_offset > 0);
3371+
try std.testing.expectEqual(max_offset, support.clampedTextInputHorizontalScrollOffsetForWidget(field, .{}, 100000));
3372+
3373+
var zero_offset_field = field;
3374+
zero_offset_field.value = 0;
3375+
3376+
var commands: [8]CanvasCommand = undefined;
3377+
var builder = Builder.init(&commands);
3378+
try emitWidgetTree(&builder, field, .{});
3379+
const display_list = builder.displayList();
3380+
var zero_commands: [8]CanvasCommand = undefined;
3381+
var zero_builder = Builder.init(&zero_commands);
3382+
try emitWidgetTree(&zero_builder, zero_offset_field, .{});
3383+
const zero_display_list = zero_builder.displayList();
3384+
3385+
// Fill, border, offset focus ring, clip, text, caret, pop.
3386+
try std.testing.expectEqual(@as(usize, 7), display_list.commandCount());
3387+
const viewport = textInputViewportForWidget(field, .{}).?;
3388+
switch (display_list.commands[3]) {
3389+
.push_clip => |clip| {
3390+
try std.testing.expectEqual(widgetPartId(7, 16), clip.id);
3391+
try expectRectApprox(viewport, clip.rect);
3392+
},
3393+
else => return error.TestUnexpectedResult,
3394+
}
3395+
// The draw-text origin shifts left by exactly the clamped offset.
3396+
const scrolled_origin = switch (display_list.commands[4]) {
3397+
.draw_text => |text| text.origin,
3398+
else => return error.TestUnexpectedResult,
3399+
};
3400+
const resting_origin = switch (zero_display_list.commands[4]) {
3401+
.draw_text => |text| text.origin,
3402+
else => return error.TestUnexpectedResult,
3403+
};
3404+
try std.testing.expectApproxEqAbs(resting_origin.x - max_offset, scrolled_origin.x, 0.001);
3405+
try std.testing.expectEqual(resting_origin.y, scrolled_origin.y);
3406+
// The end-of-value caret rides the same origin, landing inside the
3407+
// clip instead of past the field's border.
3408+
switch (display_list.commands[5]) {
3409+
.fill_rect => |caret| {
3410+
try std.testing.expect(caret.rect.x >= viewport.x - 0.001);
3411+
try std.testing.expect(caret.rect.maxX() <= viewport.maxX() + 0.001);
3412+
},
3413+
else => return error.TestUnexpectedResult,
3414+
}
3415+
try std.testing.expectEqual(CanvasCommand.pop_clip, display_list.commands[6]);
3416+
}
3417+
3418+
test "the caret keep-visible offset scrolls to the caret and returns home" {
3419+
const long_text = "a value far too long for a narrow single-line field to show at once";
3420+
var field = Widget{
3421+
.id = 7,
3422+
.kind = .text_field,
3423+
.frame = geometry.RectF.init(10, 12, 120, 32),
3424+
.text = long_text,
3425+
.text_selection = TextSelection.collapsed(long_text.len),
3426+
.state = .{ .focused = true },
3427+
.semantics = .{ .label = "Name" },
3428+
};
3429+
const max_offset = support.textInputMaxHorizontalScrollOffsetForWidget(field, .{});
3430+
try std.testing.expect(max_offset > 0);
3431+
3432+
// Caret at the end, unscrolled: the recompute scrolls forward far
3433+
// enough that the caret sits inside the visible span.
3434+
const end_offset = support.textInputCaretVisibleScrollOffsetForWidget(field, .{}, 0);
3435+
try std.testing.expect(end_offset > 0);
3436+
try std.testing.expect(end_offset <= max_offset + 0.001);
3437+
field.value = end_offset;
3438+
const viewport = textInputViewportForWidget(field, .{}).?;
3439+
const end_geometry = textGeometryForWidget(field, .{});
3440+
const end_caret = end_geometry.caret_bounds.?;
3441+
try std.testing.expect(end_caret.x >= viewport.x - 0.001);
3442+
try std.testing.expect(end_caret.maxX() <= viewport.maxX() + 0.001);
3443+
3444+
// Home: caret back at byte zero scrolls all the way back — the field
3445+
// never shows trailing emptiness while text could fill it.
3446+
field.text_selection = TextSelection.collapsed(0);
3447+
try std.testing.expectEqual(@as(f32, 0), support.textInputCaretVisibleScrollOffsetForWidget(field, .{}, end_offset));
3448+
3449+
// A value that fits never scrolls and never adjusts.
3450+
var short = field;
3451+
short.text = "short";
3452+
short.text_selection = TextSelection.collapsed(5);
3453+
try std.testing.expectEqual(@as(f32, 0), support.textInputMaxHorizontalScrollOffsetForWidget(short, .{}));
3454+
try std.testing.expectEqual(@as(f32, 0), support.textInputCaretVisibleScrollOffsetForWidget(short, .{}, 25));
3455+
}
3456+
3457+
test "scrolled single-line selection rects shift with the text origin" {
3458+
const long_text = "a value far too long for a narrow single-line field to show at once";
3459+
const scrolled = Widget{
3460+
.id = 7,
3461+
.kind = .text_field,
3462+
.frame = geometry.RectF.init(10, 12, 120, 32),
3463+
.text = long_text,
3464+
.text_selection = .{ .anchor = 2, .focus = 9 },
3465+
.state = .{ .focused = true },
3466+
.semantics = .{ .label = "Name" },
3467+
.value = 20,
3468+
};
3469+
var resting = scrolled;
3470+
resting.value = 0;
3471+
3472+
var commands: [10]CanvasCommand = undefined;
3473+
var builder = Builder.init(&commands);
3474+
try emitWidgetTree(&builder, scrolled, .{});
3475+
const display_list = builder.displayList();
3476+
var resting_commands: [10]CanvasCommand = undefined;
3477+
var resting_builder = Builder.init(&resting_commands);
3478+
try emitWidgetTree(&resting_builder, resting, .{});
3479+
const resting_display_list = resting_builder.displayList();
3480+
3481+
// Fill, border, focus ring, clip, selection rect, text, selected
3482+
// glyphs, pop — the same shape at both offsets.
3483+
try std.testing.expectEqual(display_list.commandCount(), resting_display_list.commandCount());
3484+
const scrolled_selection = switch (display_list.commands[4]) {
3485+
.fill_rect => |rect| rect.rect,
3486+
else => return error.TestUnexpectedResult,
3487+
};
3488+
const resting_selection = switch (resting_display_list.commands[4]) {
3489+
.fill_rect => |rect| rect.rect,
3490+
else => return error.TestUnexpectedResult,
3491+
};
3492+
const scrolled_text = switch (display_list.commands[5]) {
3493+
.draw_text => |text| text.origin,
3494+
else => return error.TestUnexpectedResult,
3495+
};
3496+
const resting_text = switch (resting_display_list.commands[5]) {
3497+
.draw_text => |text| text.origin,
3498+
else => return error.TestUnexpectedResult,
3499+
};
3500+
// Selection geometry and the draw-text origin move together, by
3501+
// exactly the scroll offset.
3502+
try std.testing.expectApproxEqAbs(@as(f32, 20), resting_selection.x - scrolled_selection.x, 0.001);
3503+
try std.testing.expectApproxEqAbs(@as(f32, 20), resting_text.x - scrolled_text.x, 0.001);
3504+
try std.testing.expectApproxEqAbs(resting_selection.width, scrolled_selection.width, 0.001);
3505+
}
3506+
3507+
test "short single-line values emit no clip and an unshifted origin" {
3508+
const field = Widget{
3509+
.id = 7,
3510+
.kind = .text_field,
3511+
.frame = geometry.RectF.init(10, 12, 120, 32),
3512+
.text = "short",
3513+
.text_selection = TextSelection.collapsed(5),
3514+
.state = .{ .focused = true },
3515+
.semantics = .{ .label = "Name" },
3516+
// A stale offset on a value that fits clamps to zero: fitting
3517+
// fields render exactly as they did before fields scrolled.
3518+
.value = 40,
3519+
};
3520+
var commands: [8]CanvasCommand = undefined;
3521+
var builder = Builder.init(&commands);
3522+
try emitWidgetTree(&builder, field, .{});
3523+
const display_list = builder.displayList();
3524+
// Fill, border, offset focus ring, text, caret — no clip pair.
3525+
try std.testing.expectEqual(@as(usize, 5), display_list.commandCount());
3526+
for (display_list.commands) |command| {
3527+
try std.testing.expect(command != .push_clip and command != .pop_clip);
3528+
}
3529+
var zero = field;
3530+
zero.value = 0;
3531+
var zero_commands: [8]CanvasCommand = undefined;
3532+
var zero_builder = Builder.init(&zero_commands);
3533+
try emitWidgetTree(&zero_builder, zero, .{});
3534+
const zero_display_list = zero_builder.displayList();
3535+
switch (display_list.commands[3]) {
3536+
.draw_text => |text| {
3537+
try std.testing.expectEqual(switch (zero_display_list.commands[3]) {
3538+
.draw_text => |zero_text| zero_text.origin.x,
3539+
else => return error.TestUnexpectedResult,
3540+
}, text.origin.x);
3541+
},
3542+
else => return error.TestUnexpectedResult,
3543+
}
3544+
}
3545+
3546+
test "search fields clip an overflowing value and keep chrome outside the clip" {
3547+
const long_text = "an overflowing search query that runs past the narrow field";
3548+
const field = Widget{
3549+
.id = 9,
3550+
.kind = .search_field,
3551+
.frame = geometry.RectF.init(10, 12, 140, 32),
3552+
.text = long_text,
3553+
.text_selection = TextSelection.collapsed(long_text.len),
3554+
.state = .{ .focused = true },
3555+
.semantics = .{ .label = "Search" },
3556+
.value = 100000,
3557+
};
3558+
try std.testing.expect(support.textInputMaxHorizontalScrollOffsetForWidget(field, .{}) > 0);
3559+
3560+
var commands: [24]CanvasCommand = undefined;
3561+
var builder = Builder.init(&commands);
3562+
try emitWidgetTree(&builder, field, .{});
3563+
const display_list = builder.displayList();
3564+
3565+
var clip_index: ?usize = null;
3566+
var pop_index: ?usize = null;
3567+
var text_index: ?usize = null;
3568+
var caret_index: ?usize = null;
3569+
var clear_transform_index: ?usize = null;
3570+
for (display_list.commands, 0..) |command, index| {
3571+
switch (command) {
3572+
.push_clip => |clip| {
3573+
try std.testing.expectEqual(widgetPartId(9, 7), clip.id);
3574+
try expectRectApprox(textInputViewportForWidget(field, .{}).?, clip.rect);
3575+
clip_index = index;
3576+
},
3577+
.pop_clip => pop_index = index,
3578+
.draw_text => |text| {
3579+
if (text.id == widgetPartId(9, 9)) text_index = index;
3580+
},
3581+
.fill_rect => |caret| {
3582+
if (caret.id == widgetPartId(9, 11)) caret_index = index;
3583+
},
3584+
.transform => {
3585+
if (clear_transform_index == null and pop_index != null) clear_transform_index = index;
3586+
},
3587+
else => {},
3588+
}
3589+
}
3590+
// Text and caret sit inside the clip pair; the trailing clear
3591+
// affordance draws after the pop, outside it.
3592+
try std.testing.expect(clip_index.? < text_index.?);
3593+
try std.testing.expect(text_index.? < caret_index.?);
3594+
try std.testing.expect(caret_index.? < pop_index.?);
3595+
try std.testing.expect(pop_index.? < clear_transform_index.?);
3596+
}

src/primitives/canvas/widget_render_controls.zig

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const widgetTextInputOrigin = widget_text_input.widgetTextInputOrigin;
4141
const widgetTextInputClipRect = widget_text_input.widgetTextInputClipRect;
4242
const widgetTextInputDrawText = widget_text_input.widgetTextInputDrawText;
4343
const widgetTextInputInset = widget_text_input.widgetTextInputInset;
44+
const widgetTextInputClipsText = widget_text_input.widgetTextInputClipsText;
4445
const textInputClearButtonRect = widget_text_input.textInputClearButtonRect;
4546
const widgetButtonTextSize = widget_metrics.widgetButtonTextSize;
4647
const widgetBodyTextSize = widget_metrics.widgetBodyTextSize;
@@ -466,7 +467,12 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke
466467
const selection_range = widgetTextSelectionRange(widget);
467468
const composition_range = widgetTextCompositionRange(widget);
468469
const has_text_affordances = selection_range != null or composition_range != null;
469-
const clips_text = widget.kind == .textarea;
470+
// Textareas always clip (their overflow scrolls vertically); a
471+
// single-line field clips once its value overflows the content rect,
472+
// so the horizontally scrolled text, selection rects, composition
473+
// underline, and caret all cut at the field's border instead of
474+
// painting past it. Short values emit no clip — unchanged emission.
475+
const clips_text = widgetTextInputClipsText(widget, tokens, text_size, text_inset, layout_options);
470476

471477
try builder.fillRoundedRect(.{
472478
.id = widgetPartId(widget.id, 1),
@@ -555,11 +561,20 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
555561
const icon_size = @max(8, text_size - 2);
556562
const text_inset = widgetTextInputInset(widget, tokens);
557563
const layout_options = widgetTextInputLayoutOptions(widget, tokens, text_size, text_inset);
564+
const clip_rect = widgetTextInputClipRect(widget, tokens, text_size, text_inset, layout_options);
558565
const origin = widgetTextInputOrigin(widget, tokens, text_size, text_inset, layout_options);
559566
const selection_range = widgetTextSelectionRange(widget);
560567
const composition_range = widgetTextCompositionRange(widget);
561568
const text_color = widgetForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.text);
562569
const draw_text = widgetTextInputDrawText(widget, tokens, text_size, origin, text_color, layout_options);
570+
// Same overflow contract as the text-field emitter: clip only once
571+
// the value (or placeholder) overflows the content rect, so the
572+
// horizontally scrolled text and its affordances cut at the border.
573+
// The leading glass, trailing chevron, and clear affordance draw
574+
// outside the clip — they are chrome, not scrolling content. Slot 7
575+
// is clear of the field chrome (1..6, 8..13), the focus ring (14),
576+
// and the clear affordance's shape range (15..).
577+
const clips_text = widgetTextInputClipsText(widget, tokens, text_size, text_inset, layout_options);
563578

564579
try builder.fillRoundedRect(.{
565580
.id = widgetPartId(widget.id, 1),
@@ -578,6 +593,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
578593
}));
579594
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 14, widget.frame, radius);
580595
try emitSearchFieldIcon(builder, widget, tokens, icon_size);
596+
if (clips_text) try builder.pushClip(.{ .id = widgetPartId(widget.id, 7), .rect = clip_rect, .radius = radius });
581597
if (selection_range) |range| {
582598
if (!range.isCollapsed(widget.text.len)) {
583599
try emitWidgetTextSelectionRects(builder, widget, draw_text, layout_options, range, 8, 0, 1, tokens);
@@ -609,6 +625,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
609625
}
610626
}
611627
}
628+
if (clips_text) try builder.popClip();
612629
if (widget.kind == .combobox) {
613630
try emitComboboxChevron(builder, widget, tokens, visual);
614631
}

src/primitives/canvas/widget_runtime.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ pub const textInputClearButtonHitRect = widget_text_input.textInputClearButtonHi
5050
pub const textInputContentExtentForWidget = widget_text_input.textInputContentExtentForWidget;
5151
pub const textInputMaxScrollOffsetForWidget = widget_text_input.textInputMaxScrollOffsetForWidget;
5252
pub const clampedTextInputScrollOffsetForWidget = widget_text_input.clampedTextInputScrollOffsetForWidget;
53+
pub const textInputMaxHorizontalScrollOffsetForWidget = widget_text_input.textInputMaxHorizontalScrollOffsetForWidget;
54+
pub const clampedTextInputHorizontalScrollOffsetForWidget = widget_text_input.clampedTextInputHorizontalScrollOffsetForWidget;
55+
pub const textInputCaretVisibleScrollOffsetForWidget = widget_text_input.textInputCaretVisibleScrollOffsetForWidget;
5356
pub const textGeometryForWidget = widget_text_input.textGeometryForWidget;
5457

5558
pub const WidgetLayoutTree = struct {

0 commit comments

Comments
 (0)