diff --git a/changelog.d/single-line-input-scroll.md b/changelog.d/single-line-input-scroll.md new file mode 100644 index 000000000..029131dc7 --- /dev/null +++ b/changelog.d/single-line-input-scroll.md @@ -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. diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index 3649b5261..47184952b 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -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; diff --git a/src/primitives/canvas/test_support.zig b/src/primitives/canvas/test_support.zig index 0f1b258f1..9b2080947 100644 --- a/src/primitives/canvas/test_support.zig +++ b/src/primitives/canvas/test_support.zig @@ -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; diff --git a/src/primitives/canvas/widget_render_controls.zig b/src/primitives/canvas/widget_render_controls.zig index b5f0c9569..b983626b2 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -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), @@ -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); diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index 4527f99ad..fb4b0c6ab 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -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 { diff --git a/src/primitives/canvas/widget_text_input.zig b/src/primitives/canvas/widget_text_input.zig index 2a7271bc7..b53841652 100644 --- a/src/primitives/canvas/widget_text_input.zig +++ b/src/primitives/canvas/widget_text_input.zig @@ -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 { @@ -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); } diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 28733bcd8..31cd384eb 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -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 diff --git a/src/runtime/canvas_widget_events.zig b/src/runtime/canvas_widget_events.zig index f4f26e750..d7d88958a 100644 --- a/src/runtime/canvas_widget_events.zig +++ b/src/runtime/canvas_widget_events.zig @@ -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()); diff --git a/src/runtime/canvas_widget_runtime.zig b/src/runtime/canvas_widget_runtime.zig index 6e50893f3..fa9996699 100644 --- a/src/runtime/canvas_widget_runtime.zig +++ b/src/runtime/canvas_widget_runtime.zig @@ -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; @@ -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); } } diff --git a/src/runtime/canvas_widget_text_tests.zig b/src/runtime/canvas_widget_text_tests.zig index 731c50c06..5ffcda3da 100644 --- a/src/runtime/canvas_widget_text_tests.zig +++ b/src/runtime/canvas_widget_text_tests.zig @@ -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("

Hello

") }; + } + }; + + 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, diff --git a/src/runtime/view.zig b/src/runtime/view.zig index 0af6a9247..1052f26d7 100644 --- a/src/runtime/view.zig +++ b/src/runtime/view.zig @@ -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; diff --git a/src/runtime/view_widget_scroll.zig b/src/runtime/view_widget_scroll.zig index 32f00944f..beaf697af 100644 --- a/src/runtime/view_widget_scroll.zig +++ b/src/runtime/view_widget_scroll.zig @@ -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; } }; diff --git a/src/runtime/view_widget_text.zig b/src/runtime/view_widget_text.zig index 972c367db..8dd7eb9fe 100644 --- a/src/runtime/view_widget_text.zig +++ b/src/runtime/view_widget_text.zig @@ -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; @@ -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); @@ -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);