Skip to content
Closed
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-input-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix: **Bounded single-line inputs**: overflowing input text now clips to the field viewport and scrolls horizontally to keep the active caret visible.
1 change: 1 addition & 0 deletions src/primitives/canvas/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ 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 textInputScrollOffsetToRevealCaret = widget_runtime.textInputScrollOffsetToRevealCaret;
pub const intrinsicWidgetSize = widget_runtime.intrinsicWidgetSize;
pub const cursorForWidgetHit = widget_runtime.cursorForWidgetHit;
pub const cursorForWidgetTarget = widget_runtime.cursorForWidgetTarget;
Expand Down
1 change: 1 addition & 0 deletions src/primitives/canvas/test_support.zig
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ pub const textInputViewportForWidget = canvas.textInputViewportForWidget;
pub const textInputContentExtentForWidget = canvas.textInputContentExtentForWidget;
pub const textInputMaxScrollOffsetForWidget = canvas.textInputMaxScrollOffsetForWidget;
pub const clampedTextInputScrollOffsetForWidget = canvas.clampedTextInputScrollOffsetForWidget;
pub const textInputScrollOffsetToRevealCaret = canvas.textInputScrollOffsetToRevealCaret;
pub const intrinsicWidgetSize = canvas.intrinsicWidgetSize;
pub const cursorForWidgetHit = canvas.cursorForWidgetHit;
pub const cursorForWidgetTarget = canvas.cursorForWidgetTarget;
Expand Down
7 changes: 4 additions & 3 deletions src/primitives/canvas/widget_render_controls.zig
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,10 @@ 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;
const visible_text = if (widget.text.len > 0) widget.text else widgetPlaceholder(widget);
const horizontal_overflow = layout_options.wrap == .none and
(widget.value > 0 or measureTextWidthForFont(tokens.text_measure, tokens.typography.font_id, visible_text, text_size) > clip_rect.width);
const clips_text = widget.kind == .textarea or horizontal_overflow;

try builder.fillRoundedRect(.{
.id = widgetPartId(widget.id, 1),
Expand All @@ -483,8 +486,6 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke
try emitWidgetTextSelectionRects(builder, widget, draw_text, layout_options, range, 3, 13, max_widget_text_range_rects, tokens);
}
}
const placeholder = widgetPlaceholder(widget);
const visible_text = if (widget.text.len > 0) widget.text else placeholder;
if (visible_text.len > 0) {
var command = draw_text;
command.id = widgetPartId(widget.id, if (has_text_affordances) 4 else 3);
Expand Down
1 change: 1 addition & 0 deletions src/primitives/canvas/widget_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ 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 textInputScrollOffsetToRevealCaret = widget_text_input.textInputScrollOffsetToRevealCaret;
pub const textGeometryForWidget = widget_text_input.textGeometryForWidget;

pub const WidgetLayoutTree = struct {
Expand Down
52 changes: 49 additions & 3 deletions src/primitives/canvas/widget_text_input.zig
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,19 @@ fn widgetTextInputVerticalInset(widget: Widget, tokens: DesignTokens, text_size:
}

fn widgetTextInputScrollOffset(widget: Widget, tokens: DesignTokens, text_size: f32, text_inset: f32, options: TextLayoutOptions) f32 {
if (widget.kind != .textarea) return 0;
return std.math.clamp(widget.value, 0, widgetTextInputMaxScrollOffset(widget, tokens, text_size, text_inset, options));
}

pub fn widgetTextInputOrigin(widget: Widget, tokens: DesignTokens, text_size: f32, inset: f32, options: TextLayoutOptions) geometry.PointF {
const scroll_offset = widgetTextInputScrollOffset(widget, tokens, text_size, inset, options);
if (options.wrap != .none) {
const scroll_offset = widgetTextInputScrollOffset(widget, tokens, text_size, inset, options);
return geometry.PointF.init(
widget.frame.x + inset,
widget.frame.y + widgetTextInputVerticalInset(widget, tokens, text_size, options) + text_size - scroll_offset,
);
}
return textInputOriginForFrame(widget.frame, text_size, inset);
const origin = textInputOriginForFrame(widget.frame, text_size, inset);
return geometry.PointF.init(origin.x - scroll_offset, origin.y);
}

pub fn widgetTextInputClipRect(widget: Widget, tokens: DesignTokens, text_size: f32, inset: f32, options: TextLayoutOptions) geometry.RectF {
Expand Down Expand Up @@ -149,9 +149,55 @@ pub fn clampedTextInputScrollOffsetForWidget(widget: Widget, tokens: DesignToken

fn widgetTextInputMaxScrollOffset(widget: Widget, tokens: DesignTokens, text_size: f32, text_inset: f32, options: TextLayoutOptions) f32 {
const viewport = widgetTextInputClipRect(widget, tokens, text_size, text_inset, options);
if (options.wrap == .none) {
const content_width = text_model.measureTextWidthForFont(tokens.text_measure, tokens.typography.font_id, widget.text, text_size);
return @max(0, content_width + 1 - viewport.width);
}
return @max(0, textInputContentExtentForWidgetWithOptions(widget, tokens.typography.font_id, text_size, options) - viewport.height);
}

/// The retained scroll offset that keeps the selection's focus edge in
/// the input viewport. Single-line fields move horizontally; wrapped
/// fields and textareas keep their existing vertical behavior.
pub fn textInputScrollOffsetToRevealCaret(widget: Widget, tokens: DesignTokens, padding: f32) f32 {
if (!widget_access.widgetTextInputKind(widget.kind) or widget.state.disabled) return 0;

const text_size = widgetTextInputSize(widget, tokens);
const text_inset = widgetTextInputInset(widget, tokens);
const options = widgetTextInputLayoutOptions(widget, tokens, text_size, text_inset);
const viewport = widgetTextInputClipRect(widget, tokens, text_size, text_inset, options);
const max_offset = widgetTextInputMaxScrollOffset(widget, tokens, text_size, text_inset, options);
const current = std.math.clamp(widget.value, 0, max_offset);

var scrolled = widget;
scrolled.value = current;
const origin = widgetTextInputOrigin(scrolled, tokens, text_size, text_inset, options);
const draw_text = widgetTextInputDrawText(scrolled, tokens, text_size, origin, tokens.colors.text, options);
const selection = widget.text_selection orelse TextSelection.collapsed(widget.text.len);
const caret = layoutTextCaretRect(draw_text, options, selection.focus) orelse return current;
const safe_padding = @max(0, padding);

var next = current;
if (options.wrap == .none) {
const left = viewport.x + safe_padding;
const right = viewport.maxX() - safe_padding;
if (caret.x < left) {
next -= left - caret.x;
} else if (caret.maxX() > right) {
next += caret.maxX() - right;
}
} else {
const top = viewport.y + safe_padding;
const bottom = viewport.maxY() - safe_padding;
if (caret.y < top) {
next -= top - caret.y;
} else if (caret.maxY() > bottom) {
next += caret.maxY() - bottom;
}
}
return std.math.clamp(next, 0, max_offset);
}

fn textInputContentExtentForWidgetWithOptions(widget: Widget, font_id: FontId, text_size: f32, options: TextLayoutOptions) f32 {
return @as(f32, @floatFromInt(widgetTextInputLineCount(widget, font_id, text_size, options))) * widgetTextInputLineHeight(text_size);
}
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/automation_widget_dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,17 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type {
const previous_state = self.views[view_index].canvasWidgetRenderState();
self.views[view_index].canvas_widget_focused_id = target.id;
self.views[view_index].canvas_widget_focus_visible_id = focus_visible_id;
if (self.views[view_index].canEditCanvasWidgetText(target.id)) {
if (self.views[view_index].canvasWidgetNodeIndexById(target.id)) |node_index| {
const widget = &self.views[view_index].widget_layout_nodes[node_index].widget;
if (widget.text_selection == null) {
widget.text_selection = canvas.TextSelection.collapsed(widget.text.len);
try self.views[view_index].refreshCanvasWidgetSemantics();
self.views[view_index].widget_revision += 1;
}
self.views[view_index].scrollCanvasTextInputCaretIntoView(node_index);
}
}
// A focus change repaints; record the automation input so
// the completing frame publishes (same contract as select
// and text edits). Callers that dispatch a follow-up input
Expand Down
1 change: 1 addition & 0 deletions src/runtime/canvas_widget_events.zig
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,7 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
try self.views[view_index].refreshCanvasWidgetSemantics();
self.views[view_index].widget_revision += 1;
}
self.views[view_index].scrollCanvasTextInputCaretIntoView(node_index);
}
}
try invalidateForCanvasWidgetRenderStateChange(self, view_index, previous_state, self.views[view_index].canvasWidgetRenderState());
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/canvas_widget_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ pub fn canvasWidgetLayoutNodeWithTextReconcileState(
const source_matches_runtime_text = std.mem.eql(u8, entry.text, copy.widget.text);
if (!source_unchanged and !source_matches_runtime_text) return copy;
if (source_unchanged) copy.widget.text = entry.text;
if (copy.widget.kind == .textarea) copy.widget.value = entry.value;
copy.widget.value = entry.value;
if (copy.widget.text_selection == null and copy.widget.text_composition == null) {
copy.widget.text_selection = entry.text_selection;
copy.widget.text_composition = entry.text_composition;
Expand Down Expand Up @@ -903,7 +903,7 @@ pub fn clampCanvasWidgetLayoutScrollOffsets(nodes: []canvas.WidgetLayoutNode, st

pub fn clampCanvasWidgetLayoutTextOffsets(nodes: []canvas.WidgetLayoutNode, tokens: canvas.DesignTokens) void {
for (nodes) |*node| {
if (node.widget.kind != .textarea) continue;
if (!canvasWidgetEditableTextKind(node.widget.kind)) continue;
node.widget.value = canvas.clampedTextInputScrollOffsetForWidget(node.widget, tokens, node.widget.value);
}
}
Expand Down
76 changes: 76 additions & 0 deletions src/runtime/canvas_widget_text_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,82 @@ test "runtime exposes retained canvas widget text geometry" {
try std.testing.expectError(error.InvalidCommand, harness.runtime.canvasWidgetTextGeometry(1, "canvas", 99));
}

test "single-line text fields clip overflow and scroll to keep the caret visible" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-text-horizontal-scroll", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};

const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
try harness.start(app_state.app());

_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 180, 80),
});

const text_field = canvas.Widget{
.id = 2,
.kind = .text_field,
.frame = geometry.RectF.init(12, 16, 100, 36),
.text = "abcdefghijklmnopqrstuvwxyz",
.text_selection = canvas.TextSelection.collapsed(26),
.semantics = .{ .label = "Name" },
};
var nodes: [2]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{text_field} }, geometry.RectF.init(0, 0, 180, 80), &nodes);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);

_ = try harness.runtime.editCanvasWidgetText(1, "canvas", 2, .{ .insert_text = "!" });
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expect(retained.nodes[1].widget.value > 0);
const viewport = canvas.textInputViewportForWidget(retained.nodes[1].widget, .{}).?;
const caret = (try harness.runtime.canvasWidgetTextGeometry(1, "canvas", 2)).caret_bounds.?;
try std.testing.expect(caret.x >= viewport.x - 0.001);
try std.testing.expect(caret.maxX() <= viewport.maxX() + 0.001);

_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqualStrings("abcdefghijklmnopqrstuvwxyz!", retained.nodes[1].widget.text);
try std.testing.expect(retained.nodes[1].widget.value > 0);

_ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{});
const display_list = try harness.runtime.canvasDisplayList(1, "canvas");
var saw_viewport_clip = false;
var saw_shifted_text = false;
for (display_list.commands) |command| {
switch (command) {
.push_clip => |clip| {
if (clip.id == testCanvasWidgetPartId(2, 16)) {
try std.testing.expectEqualDeep(viewport, clip.rect);
saw_viewport_clip = true;
}
},
.draw_text => |text| {
if (std.mem.eql(u8, text.text, "abcdefghijklmnopqrstuvwxyz!")) {
try std.testing.expect(text.origin.x < viewport.x);
saw_shifted_text = true;
}
},
else => {},
}
}
try std.testing.expect(saw_viewport_clip);
try std.testing.expect(saw_shifted_text);

_ = try harness.runtime.editCanvasWidgetText(1, "canvas", 2, .{ .set_selection = canvas.TextSelection.collapsed(0) });
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqual(@as(f32, 0), retained.nodes[1].widget.value);
const leading_caret = (try harness.runtime.canvasWidgetTextGeometry(1, "canvas", 2)).caret_bounds.?;
try std.testing.expect(leading_caret.x >= viewport.x - 0.001);
}

test "runtime applies text input to focused canvas text fields" {
const TestApp = struct {
widget_keyboard_count: u32 = 0,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ pub const RuntimeView = struct {
pub const stepCanvasWidgetKineticScroll = CanvasWidgetScrollMethods.stepCanvasWidgetKineticScroll;
pub const canvasWidgetScrollContentExtent = CanvasWidgetScrollMethods.canvasWidgetScrollContentExtent;
pub const translateCanvasWidgetScrollDescendants = CanvasWidgetScrollMethods.translateCanvasWidgetScrollDescendants;
pub const scrollCanvasTextareaCaretIntoView = CanvasWidgetScrollMethods.scrollCanvasTextareaCaretIntoView;
pub const scrollCanvasTextInputCaretIntoView = CanvasWidgetScrollMethods.scrollCanvasTextInputCaretIntoView;

const CanvasWidgetControlMethods = view_widget_control.RuntimeViewCanvasWidgetControl(RuntimeView);
pub const canvasWidgetToggleAnimation = CanvasWidgetControlMethods.canvasWidgetToggleAnimation;
Expand Down
20 changes: 3 additions & 17 deletions src/runtime/view_widget_scroll.zig
Original file line number Diff line number Diff line change
Expand Up @@ -335,25 +335,11 @@ pub fn RuntimeViewCanvasWidgetScroll(comptime RuntimeView: type) type {
}
}

pub fn scrollCanvasTextareaCaretIntoView(self: *RuntimeView, index: usize) void {
pub fn scrollCanvasTextInputCaretIntoView(self: *RuntimeView, index: usize) void {
if (index >= self.widget_layout_node_count) return;
var widget = self.widget_layout_nodes[index].widget;
if (widget.kind != .textarea) return;

const viewport = canvas.textInputViewportForWidget(widget, self.widget_tokens) orelse return;
const geometry_value = canvas.textGeometryForWidget(widget, self.widget_tokens);
const caret = geometry_value.caret_bounds orelse return;

var next_offset = canvas.clampedTextInputScrollOffsetForWidget(widget, self.widget_tokens, widget.value);
const padding: f32 = 2;
if (caret.y < viewport.y) {
next_offset -= viewport.y - caret.y + padding;
} else if (caret.maxY() > viewport.maxY()) {
next_offset += caret.maxY() - viewport.maxY() + padding;
}
next_offset = canvas.clampedTextInputScrollOffsetForWidget(widget, self.widget_tokens, next_offset);
const widget = self.widget_layout_nodes[index].widget;
const next_offset = canvas.textInputScrollOffsetToRevealCaret(widget, self.widget_tokens, 2);
if (next_offset == widget.value) return;
widget.value = next_offset;
self.widget_layout_nodes[index].widget.value = next_offset;
}
};
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/view_widget_text.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
if (canvasWidgetTextEditUnchanged(current_state, next_state)) return null;

try self.rewriteCanvasWidgetTextStorage(index, next_state);
self.scrollCanvasTextareaCaretIntoView(index);
self.scrollCanvasTextInputCaretIntoView(index);
const semantics = try self.widgetLayoutTree().collectSemantics(&self.widget_semantics_nodes);
self.widget_semantics_node_count = semantics.len;
self.widget_revision += 1;
Expand Down Expand Up @@ -98,6 +98,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {

self.widget_layout_nodes[index].widget.text_selection = next_selection;
self.widget_layout_nodes[index].widget.text_composition = null;
self.scrollCanvasTextInputCaretIntoView(index);
try self.refreshCanvasWidgetSemantics();
self.widget_revision += 1;
return self.canvasWidgetDirtyBounds(index, widget.frame);
Expand Down Expand Up @@ -266,7 +267,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
.selection = canvas.TextSelection.collapsed(text.len),
.composition = null,
});
self.scrollCanvasTextareaCaretIntoView(index);
self.scrollCanvasTextInputCaretIntoView(index);
try self.refreshCanvasWidgetSemantics();
self.widget_revision += 1;
return self.canvasWidgetDirtyBounds(index, self.widget_layout_nodes[index].frame);
Expand Down