From acfa8eb8a73d13df8a59d8abd668e5074e72f884 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 18:10:47 +0300 Subject: [PATCH 1/6] fix(textfield): round base X in CursorX/RuneIndexFromX to match DrawText (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawText rounds text origin with math.Round(x), but CursorX used raw contentRect.Min.X — up to 0.5px constant offset. Now both agree. --- internal/textmetrics/textmetrics.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/textmetrics/textmetrics.go b/internal/textmetrics/textmetrics.go index 3109760..8540201 100644 --- a/internal/textmetrics/textmetrics.go +++ b/internal/textmetrics/textmetrics.go @@ -11,6 +11,8 @@ package textmetrics import ( + "math" + "github.com/gogpu/ui/geometry" "github.com/gogpu/ui/widget" ) @@ -23,16 +25,20 @@ type Metrics struct { // CursorX returns the X coordinate for a cursor at the given rune position // within the content rect. Uses MeasureText for accurate positioning. +// +// The base X is rounded to match DrawText's pixel-grid rounding (math.Round), +// ensuring the cursor aligns with the rendered text origin. func (m *Metrics) CursorX(contentRect geometry.Rect, displayText string, runePos int) float32 { + baseX := float32(math.Round(float64(contentRect.Min.X))) runes := []rune(displayText) if runePos > len(runes) { runePos = len(runes) } if runePos <= 0 { - return contentRect.Min.X + return baseX } textBefore := string(runes[:runePos]) - x := contentRect.Min.X + m.Canvas.MeasureText(textBefore, m.FontSize, false) + x := baseX + m.Canvas.MeasureText(textBefore, m.FontSize, false) if x > contentRect.Max.X { x = contentRect.Max.X } @@ -41,8 +47,11 @@ func (m *Metrics) CursorX(contentRect geometry.Rect, displayText string, runePos // RuneIndexFromX converts an X coordinate to a rune index (for hit-testing). // Returns the rune position closest to the given X within the content rect. +// +// The base X is rounded to match DrawText's pixel-grid rounding. func (m *Metrics) RuneIndexFromX(contentRect geometry.Rect, displayText string, x float32) int { - localX := x - contentRect.Min.X + baseX := float32(math.Round(float64(contentRect.Min.X))) + localX := x - baseX if localX <= 0 { return 0 } From b946647ccebe59058c0e45aab91b41720d690b31 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 18:40:54 +0300 Subject: [PATCH 2/6] =?UTF-8?q?chore(deps):=20update=20gg=20v0.50.12=20?= =?UTF-8?q?=E2=86=92=20v0.50.13=20(hinted=20advance=20fix=20#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 82a795e..66ff0ae 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/coregx/signals v0.1.1 - github.com/gogpu/gg v0.50.12 + github.com/gogpu/gg v0.50.13 github.com/gogpu/gogpu v0.50.0 github.com/gogpu/gpucontext v0.24.0 github.com/gogpu/gputypes v0.5.1 diff --git a/go.sum b/go.sum index 5026f8e..ed586d3 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/go-webgpu/goffi v0.6.3 h1:p4gKGikHBAQ/8iUiew9MV4C5M1ZGIsk8QGFGuJjMj+A github.com/go-webgpu/goffi v0.6.3/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/go-webgpu/webgpu v0.5.5 h1:pIrXzRg0LRlNjNmR+ZNo/ERN88YaObzbCY5oYhir5SA= github.com/go-webgpu/webgpu v0.5.5/go.mod h1:vgIuNTa1UlZ4njCGY6Pmp/0c5T5o2Ml2fQ0znLc7B98= -github.com/gogpu/gg v0.50.12 h1:rPhZGx5uTvd4AsARSfi3Uj7bLyYoXpUOUZ09uibg45M= -github.com/gogpu/gg v0.50.12/go.mod h1:cV+vVHM75pf7qPDIKPLSFUQuazolClb8xSW9tdQTJRU= +github.com/gogpu/gg v0.50.13 h1:GIDqgR6CozprmFD3MgeMhFkXbZ76FH5ofZuwblCPOy4= +github.com/gogpu/gg v0.50.13/go.mod h1:cV+vVHM75pf7qPDIKPLSFUQuazolClb8xSW9tdQTJRU= github.com/gogpu/gogpu v0.50.0 h1:qyUi5vBte/m7NDd49aN+YJffn36V4jGJydXCg37NR9E= github.com/gogpu/gogpu v0.50.0/go.mod h1:hIEFjkuVV2+eOkayR7gS38h7UrnUOZfcQXXQbxt36c0= github.com/gogpu/gpucontext v0.24.0 h1:YQ0FxGqXEO8LQB+6/TOqZOV1fn0mO9UDYR0obSU3R6o= From 95c0782e0be879ff751cdea80892a3af40f6311a Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 19:01:06 +0300 Subject: [PATCH 3/6] feat(textfield): horizontal scroll + TextRect for overflow (#212) TextField scrolls text horizontally when content exceeds visible width. Cursor stays near right edge via ensureCursorVisible (Flutter pattern). TextRect in PaintState separates text origin from clip rect. All 4 theme painters use TextRect for DrawText, ContentRect for PushClip. Known: cursor-scroll sync has drift on fast input (investigating). --- core/textfield/event.go | 10 +- core/textfield/painter.go | 8 +- core/textfield/textfield_test.go | 306 +++++++++++++++++++++++++++++++ core/textfield/widget.go | 94 +++++++++- theme/cupertino/textfield.go | 2 +- theme/devtools/textfield.go | 2 +- theme/fluent/textfield.go | 2 +- theme/material3/textfield.go | 2 +- 8 files changed, 415 insertions(+), 11 deletions(-) diff --git a/core/textfield/event.go b/core/textfield/event.go index 965bbe2..3f3e469 100644 --- a/core/textfield/event.go +++ b/core/textfield/event.go @@ -112,15 +112,21 @@ func handleDoubleClick(w *Widget, ctx widget.Context, e *event.MouseEvent) bool // Uses cached text metrics from the last Draw call for accurate hit-testing. // Falls back to proportional approximation when no cached metrics are available // (e.g., before the first draw). +// +// When horizontal scroll is active, the mouse X is adjusted by the inverse +// of scrollOffsetX to map screen coordinates back to text coordinates. func positionFromMouse(w *Widget, e *event.MouseEvent) int { runes := w.textRunes() // Use cached metrics from last Draw if available. if w.cachedMetrics != nil { + // Adjust mouse X by inverse of scroll offset: the text is shifted + // by scrollOffsetX, so the unscrolled X is (mouseX - scrollOffsetX). + adjustedX := e.Position.X - w.scrollOffsetX return w.cachedMetrics.RuneIndexFromX( w.cachedContentRect, w.cachedDisplayText, - e.Position.X, + adjustedX, ) } @@ -128,7 +134,7 @@ func positionFromMouse(w *Widget, e *event.MouseEvent) int { lm := resolveLayoutMetrics(w.painter) hPad, _ := lm.ContentPadding() bounds := w.Bounds() - localX := e.Position.X - bounds.Min.X - hPad + localX := e.Position.X - bounds.Min.X - hPad - w.scrollOffsetX if localX <= 0 { return 0 diff --git a/core/textfield/painter.go b/core/textfield/painter.go index 7be05aa..410f251 100644 --- a/core/textfield/painter.go +++ b/core/textfield/painter.go @@ -41,8 +41,14 @@ type PaintState struct { DisplayText string // ContentRect is the inner text area (bounds minus theme padding). + // Used for clipping (PushClip) — NOT shifted by scroll. ContentRect geometry.Rect + // TextRect is the text drawing area, shifted by horizontal scroll offset. + // When text overflows, TextRect.Min.X < ContentRect.Min.X (text shifted left). + // Painters draw text into TextRect but clip to ContentRect. + TextRect geometry.Rect + // CursorRect is the cursor line rectangle (zero if no cursor). CursorRect geometry.Rect @@ -192,7 +198,7 @@ func paintContent(canvas widget.Canvas, st *PaintState, colors TextFieldColorSch canvas.DrawRect(st.SelectionRect, colors.SelectionBg) } - canvas.DrawText(st.DisplayText, st.ContentRect, fontSize, textColor, false, textAlignLeft) + canvas.DrawText(st.DisplayText, st.TextRect, fontSize, textColor, false, textAlignLeft) } // paintCursorFromState draws the cursor using pre-computed CursorRect. diff --git a/core/textfield/textfield_test.go b/core/textfield/textfield_test.go index 4cfcf1d..f68f1c7 100644 --- a/core/textfield/textfield_test.go +++ b/core/textfield/textfield_test.go @@ -1161,6 +1161,312 @@ func TestPaintState_ColorScheme(t *testing.T) { _ = ps } +// --- Horizontal Scroll Tests (Issue #212) --- + +// narrowFieldWidth creates a narrow field width where text will overflow. +// With default painter: contentPaddingH=12 on each side, so content area = 80-24 = 56px. +// With MeasureText returning len(runes)*fontSize*0.5, at fontSize=14 each rune = 7px. +// So 8 runes = 56px fills the content area, 9+ triggers scrolling. +const narrowFieldWidth float32 = 80 + +func newNarrowField(text string) (*textfield.Widget, widget.Context, *testPainter) { + p := &testPainter{} + tf := textfield.New( + textfield.InitialValue(text), + textfield.PainterOpt(p), + ) + tf.SetBounds(geometry.NewRect(0, 0, narrowFieldWidth, 48)) + tf.SetFocused(true) + ctx := widget.NewContext() + return tf, ctx, p +} + +func TestScroll_NoScrollWhenTextFits(t *testing.T) { + tf, ctx, p := newNarrowField("short") + canvas := &mockCanvas{} + + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() != 0 { + t.Errorf("scrollOffsetX = %v, want 0 (text fits)", tf.ScrollOffsetX()) + } + // Cursor should be within content rect. + if p.state.ShowCursor && p.state.CursorRect.Min.X < p.state.ContentRect.Min.X { + t.Error("cursor should be within content rect when text fits") + } +} + +func TestScroll_ScrollsWhenTextOverflows(t *testing.T) { + // "abcdefghijklmnop" = 16 runes * 7px = 112px, content area ~56px. + // Cursor starts at end (position 16). Text must scroll left. + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() >= 0 { + t.Errorf("scrollOffsetX = %v, want < 0 (text overflows, cursor at end)", tf.ScrollOffsetX()) + } +} + +func TestScroll_CursorVisibleAfterTyping(t *testing.T) { + tf, ctx, p := newNarrowField("") + canvas := &mockCanvas{} + + // Type characters until text overflows the content area. + for _, r := range "abcdefghijklmnop" { + typeRune(tf, ctx, r) + } + + tf.Draw(ctx, canvas) + + // Cursor must be visible within content rect. + if p.state.ShowCursor { + cr := p.state.CursorRect + ct := p.state.ContentRect + if cr.Min.X < ct.Min.X || cr.Min.X > ct.Max.X { + t.Errorf("cursor at X=%v outside content rect [%v, %v]", + cr.Min.X, ct.Min.X, ct.Max.X) + } + } +} + +func TestScroll_HomeResetsScroll(t *testing.T) { + tf, ctx, p := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Draw once to establish scroll state (cursor at end). + tf.Draw(ctx, canvas) + scrollBefore := tf.ScrollOffsetX() + if scrollBefore >= 0 { + t.Fatalf("precondition failed: scrollOffsetX = %v, want < 0", scrollBefore) + } + + // Press Home to go to position 0. + pressKey(tf, ctx, event.KeyHome, event.ModNone) + tf.Draw(ctx, canvas) + + // After Home, cursor is at position 0. Scroll should adjust toward 0 + // (showing text from the beginning). + if tf.ScrollOffsetX() != 0 { + t.Errorf("scrollOffsetX = %v after Home, want 0", tf.ScrollOffsetX()) + } + + // Cursor should be near the left edge of content rect. + if p.state.ShowCursor { + cr := p.state.CursorRect + ct := p.state.ContentRect + // Cursor at Home should be at or very near the content rect left edge. + if cr.Min.X < ct.Min.X || cr.Min.X > ct.Min.X+10 { + t.Errorf("cursor at Home X=%v, expected near content left %v", cr.Min.X, ct.Min.X) + } + } +} + +func TestScroll_EndScrollsToShowCursor(t *testing.T) { + tf, ctx, p := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Move to Home first. + pressKey(tf, ctx, event.KeyHome, event.ModNone) + tf.Draw(ctx, canvas) + if tf.ScrollOffsetX() != 0 { + t.Fatalf("precondition failed: scroll should be 0 after Home") + } + + // Press End to go to end. + pressKey(tf, ctx, event.KeyEnd, event.ModNone) + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() >= 0 { + t.Errorf("scrollOffsetX = %v after End, want < 0", tf.ScrollOffsetX()) + } + + // Cursor at end should be visible within content rect. + if p.state.ShowCursor { + cr := p.state.CursorRect + ct := p.state.ContentRect + if cr.Min.X > ct.Max.X { + t.Errorf("cursor at End X=%v exceeds content right edge %v", cr.Min.X, ct.Max.X) + } + } +} + +func TestScroll_ArrowLeftScrollsBack(t *testing.T) { + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Cursor starts at end, text is scrolled left. + tf.Draw(ctx, canvas) + scrollEnd := tf.ScrollOffsetX() + + // Press left arrow multiple times to move cursor back. + for i := 0; i < 10; i++ { + pressKey(tf, ctx, event.KeyLeft, event.ModNone) + } + tf.Draw(ctx, canvas) + + // Scroll should have changed (less negative or zero). + if tf.ScrollOffsetX() <= scrollEnd { + t.Errorf("scrollOffsetX = %v after leftward movement, expected > %v", + tf.ScrollOffsetX(), scrollEnd) + } +} + +func TestScroll_BackspaceAdjustsScroll(t *testing.T) { + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Draw to establish initial scroll. + tf.Draw(ctx, canvas) + + // Delete all characters via backspace. + for range 16 { + pressKey(tf, ctx, event.KeyBackspace, event.ModNone) + } + tf.Draw(ctx, canvas) + + // After deleting all text, scroll should reset to 0. + if tf.ScrollOffsetX() != 0 { + t.Errorf("scrollOffsetX = %v after deleting all text, want 0", tf.ScrollOffsetX()) + } +} + +func TestScroll_OffsetNeverPositive(t *testing.T) { + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Home. + pressKey(tf, ctx, event.KeyHome, event.ModNone) + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() > 0 { + t.Errorf("scrollOffsetX = %v, must never be > 0", tf.ScrollOffsetX()) + } + + // Keep pressing left at position 0. + pressKey(tf, ctx, event.KeyLeft, event.ModNone) + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() > 0 { + t.Errorf("scrollOffsetX = %v after left at pos 0, must never be > 0", tf.ScrollOffsetX()) + } +} + +func TestScroll_ScrollOffsetXGetter(t *testing.T) { + tf := textfield.New() + if tf.ScrollOffsetX() != 0 { + t.Errorf("new widget scrollOffsetX = %v, want 0", tf.ScrollOffsetX()) + } +} + +func TestScroll_CursorRectWithinContentRect(t *testing.T) { + // Verify cursor rect stays within content rect bounds after scrolling. + tf, ctx, p := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Test at various cursor positions. + positions := []event.Key{event.KeyHome, event.KeyEnd} + for _, key := range positions { + pressKey(tf, ctx, key, event.ModNone) + tf.Draw(ctx, canvas) + + if p.state.ShowCursor { + cr := p.state.CursorRect + ct := p.state.ContentRect + // CursorRect.Min.X should be within ContentRect horizontal bounds + // (with a small margin tolerance for scrollMargin). + if cr.Min.X < ct.Min.X-1 || cr.Min.X > ct.Max.X+1 { + t.Errorf("key=%v: cursor X=%v outside content rect [%v, %v]", + key, cr.Min.X, ct.Min.X, ct.Max.X) + } + } + } +} + +func TestScroll_ContentRectUnchangedByScroll(t *testing.T) { + // Verify that ContentRect in PaintState is the original (unscrolled) rect, + // ensuring painters clip to the correct visible area. + tf, ctx, p := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + tf.Draw(ctx, canvas) + + // ContentRect should match the bounds minus padding, NOT shifted by scroll. + bounds := tf.Bounds() + cr := p.state.ContentRect + if cr.Min.X <= bounds.Min.X { + t.Errorf("ContentRect.Min.X=%v should be > bounds.Min.X=%v (padding)", cr.Min.X, bounds.Min.X) + } + if cr.Max.X >= bounds.Max.X { + t.Errorf("ContentRect.Max.X=%v should be < bounds.Max.X=%v (padding)", cr.Max.X, bounds.Max.X) + } +} + +func TestScroll_MouseClickWithScroll(t *testing.T) { + // When text is scrolled, a click at the left edge of the field + // should position the cursor at the first visible rune, not rune 0. + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + + // Draw to establish scroll state (cursor at end, text scrolled left). + tf.Draw(ctx, canvas) + if tf.ScrollOffsetX() >= 0 { + t.Fatalf("precondition: expected scroll < 0 for overflowing text") + } + + // Click at the left edge of the content area (just inside padding). + // With scroll active, this should NOT place cursor at position 0. + leftEdge := geometry.Pt(13, 24) // Just past the 12px left padding. + press := event.NewMouseEvent(event.MousePress, event.ButtonLeft, event.ButtonStateLeft, + leftEdge, leftEdge, event.ModNone) + tf.Event(ctx, press) + + // The cursor should be at a position > 0 because text is scrolled. + if tf.CursorPosition() == 0 { + t.Error("click at left edge with scroll should not place cursor at position 0") + } +} + +func TestScroll_DeleteReducesScroll(t *testing.T) { + // After deleting text that makes the remaining text fit, scroll should reset. + tf, ctx, _ := newNarrowField("abcdefghijklmnop") + canvas := &mockCanvas{} + tf.Draw(ctx, canvas) + + // Select all and delete. + pressKey(tf, ctx, event.KeyA, event.ModCtrl) + pressKey(tf, ctx, event.KeyBackspace, event.ModNone) + tf.Draw(ctx, canvas) + + if tf.ScrollOffsetX() != 0 { + t.Errorf("scrollOffsetX = %v after clearing all text, want 0", tf.ScrollOffsetX()) + } +} + +func TestScroll_PasteTriggersScroll(t *testing.T) { + tf, ctx, _ := newNarrowField("") + canvas := &mockCanvas{} + + // Type "ab", select all, copy. + typeRune(tf, ctx, 'a') + typeRune(tf, ctx, 'b') + pressKey(tf, ctx, event.KeyA, event.ModCtrl) + pressKey(tf, ctx, event.KeyC, event.ModCtrl) + pressKey(tf, ctx, event.KeyEnd, event.ModNone) + + // Paste many times to overflow. + for range 10 { + pressKey(tf, ctx, event.KeyV, event.ModCtrl) + } + tf.Draw(ctx, canvas) + + // Text should now be "ab" * 10 + "ab" = 22 chars, definitely overflowing. + if tf.ScrollOffsetX() >= 0 { + t.Errorf("scrollOffsetX = %v after pasting overflow text, want < 0", tf.ScrollOffsetX()) + } +} + // --- Helper functions --- func typeRune(tf *textfield.Widget, ctx widget.Context, r rune) bool { diff --git a/core/textfield/widget.go b/core/textfield/widget.go index f3451a5..589a86a 100644 --- a/core/textfield/widget.go +++ b/core/textfield/widget.go @@ -1,6 +1,8 @@ package textfield import ( + "fmt" + "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" "github.com/gogpu/ui/internal/textmetrics" @@ -39,6 +41,12 @@ type Widget struct { // Styling overrides set via fluent methods. padding float32 + // Horizontal scroll offset (always <= 0). When text exceeds the visible + // content area, this shifts the text left so the cursor stays visible. + // Enterprise references: Flutter RenderEditable._showCaretOnScreen(), + // Qt QLineEdit d->hscroll, HTML input.scrollLeft. + scrollOffsetX float32 + // Cached text metrics from last Draw call, used by event handlers // (positionFromMouse) that don't have access to canvas. cachedMetrics *textmetrics.Metrics @@ -149,24 +157,34 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { // Build text metrics for cursor/selection computation. tm := &textmetrics.Metrics{Canvas: canvas, FontSize: fontSize} + // Ensure cursor is visible within the content rect (adjusts scrollOffsetX). + w.ensureCursorVisible(tm, contentRect, displayText) + + // Create a scrolled content rect for text/cursor/selection positioning. + // The scrolled rect shifts the text origin by scrollOffsetX while the + // clip rect (ContentRect) stays at the original position. + scrolledRect := contentRect + scrolledRect.Min.X += w.scrollOffsetX + scrolledRect.Max.X += w.scrollOffsetX + // Cache for event handlers (positionFromMouse). w.cachedMetrics = tm w.cachedContentRect = contentRect w.cachedDisplayText = displayText w.cachedFontSize = fontSize - // Compute cursor rect (if applicable). + // Compute cursor rect (if applicable) using the scrolled content rect. showCursor := focused && !disabled && !hasSelection var cursorRect geometry.Rect if showCursor { - cursorRect = tm.CursorRect(contentRect, displayText, w.sel.cursor, cw) + cursorRect = tm.CursorRect(scrolledRect, displayText, w.sel.cursor, cw) } - // Compute selection rect (if applicable). + // Compute selection rect (if applicable) using the scrolled content rect. showSelection := hasSelection var selectionRect geometry.Rect if showSelection { - selectionRect = tm.SelectionRect(contentRect, displayText, w.sel.anchor, w.sel.cursor) + selectionRect = tm.SelectionRect(scrolledRect, displayText, w.sel.anchor, w.sel.cursor) } w.painter.PaintTextField(canvas, &PaintState{ @@ -187,6 +205,7 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { // Pre-computed fields. DisplayText: displayText, ContentRect: contentRect, + TextRect: scrolledRect, CursorRect: cursorRect, SelectionRect: selectionRect, ShowCursor: showCursor, @@ -195,6 +214,73 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { }) } +// scrollMargin is the horizontal margin in pixels to keep between the cursor +// and the visible edge when scrolling. Prevents the cursor from sitting +// exactly at the boundary, matching Flutter's _kCaretGap behavior. +const scrollMargin float32 = 2 + +// ensureCursorVisible adjusts scrollOffsetX so the cursor stays within the +// visible content rect. Called during Draw() after layout metrics are resolved. +// +// scrollOffsetX is always <= 0 (text shifts left when overflowing right). +// When text fits entirely within contentRect, scrollOffsetX is clamped to 0. +func (w *Widget) ensureCursorVisible(tm *textmetrics.Metrics, contentRect geometry.Rect, displayText string) { + // Measure the full text width. + fullTextWidth := tm.Canvas.MeasureText(displayText, tm.FontSize, false) + contentWidth := contentRect.Width() + + // If text fits, no scrolling needed. + if fullTextWidth <= contentWidth { + w.scrollOffsetX = 0 + return + } + + // Compute cursor X offset from content origin using MeasureText directly + // (NOT CursorX, which clamps to contentRect.Max.X and would hide overflow). + runes := []rune(displayText) + runePos := w.sel.cursor + if runePos > len(runes) { + runePos = len(runes) + } + var cursorRelative float32 + if runePos > 0 { + cursorRelative = tm.Canvas.MeasureText(string(runes[:runePos]), tm.FontSize, false) + } + + // Apply current scroll offset to get the visual cursor position. + visualCursorX := cursorRelative + w.scrollOffsetX + + fmt.Printf("[SCROLL] cursor=%d cursorRel=%.1f scrollX=%.1f visualX=%.1f contentW=%.1f textW=%.1f\n", + w.sel.cursor, cursorRelative, w.scrollOffsetX, visualCursorX, contentWidth, fullTextWidth) + + // If cursor is past the right edge, scroll left to reveal it. + if visualCursorX > contentWidth-scrollMargin { + w.scrollOffsetX = contentWidth - scrollMargin - cursorRelative + } + + // If cursor is past the left edge, scroll right to reveal it. + if visualCursorX < scrollMargin { + w.scrollOffsetX = scrollMargin - cursorRelative + } + + // Clamp: never scroll right of origin (would show empty space on left). + if w.scrollOffsetX > 0 { + w.scrollOffsetX = 0 + } + + // Clamp: never scroll so far left that right side shows empty space. + maxScroll := -(fullTextWidth - contentWidth) + if w.scrollOffsetX < maxScroll { + w.scrollOffsetX = maxScroll + } +} + +// ScrollOffsetX returns the current horizontal scroll offset. +// This value is always <= 0. A value of 0 means no scrolling. +func (w *Widget) ScrollOffsetX() float32 { + return w.scrollOffsetX +} + // resolveLayoutMetrics returns the LayoutMetrics from the painter if it // implements that interface, otherwise returns DefaultPainter metrics. func resolveLayoutMetrics(p Painter) LayoutMetrics { diff --git a/theme/cupertino/textfield.go b/theme/cupertino/textfield.go index 3d44356..33d05dc 100644 --- a/theme/cupertino/textfield.go +++ b/theme/cupertino/textfield.go @@ -127,7 +127,7 @@ func cupPaintTFContent(canvas widget.Canvas, st *textfield.PaintState, colors te canvas.DrawRect(st.SelectionRect, colors.SelectionBg) } - canvas.DrawText(st.DisplayText, st.ContentRect, fontSize, textColor, false, cupTFTextAlignLeft) + canvas.DrawText(st.DisplayText, st.TextRect, fontSize, textColor, false, cupTFTextAlignLeft) } // cupPaintTFCursorFromState draws the cursor using pre-computed CursorRect. diff --git a/theme/devtools/textfield.go b/theme/devtools/textfield.go index 1cd52da..520c5dd 100644 --- a/theme/devtools/textfield.go +++ b/theme/devtools/textfield.go @@ -129,7 +129,7 @@ func dtPaintTFContent(canvas widget.Canvas, st *textfield.PaintState, colors tex canvas.DrawRect(st.SelectionRect, colors.SelectionBg) } - canvas.DrawText(st.DisplayText, st.ContentRect, fontSize, textColor, false, dtTFTextAlignLeft) + canvas.DrawText(st.DisplayText, st.TextRect, fontSize, textColor, false, dtTFTextAlignLeft) } // dtPaintTFCursorFromState draws the cursor using pre-computed CursorRect. diff --git a/theme/fluent/textfield.go b/theme/fluent/textfield.go index 7c6390d..ca22dfb 100644 --- a/theme/fluent/textfield.go +++ b/theme/fluent/textfield.go @@ -128,7 +128,7 @@ func flPaintTFContent(canvas widget.Canvas, st *textfield.PaintState, colors tex canvas.DrawRect(st.SelectionRect, colors.SelectionBg) } - canvas.DrawText(st.DisplayText, st.ContentRect, fontSize, textColor, false, flTFTextAlignLeft) + canvas.DrawText(st.DisplayText, st.TextRect, fontSize, textColor, false, flTFTextAlignLeft) } // flPaintTFCursorFromState draws the cursor using pre-computed CursorRect. diff --git a/theme/material3/textfield.go b/theme/material3/textfield.go index 4d7a6a1..f3d8357 100644 --- a/theme/material3/textfield.go +++ b/theme/material3/textfield.go @@ -128,7 +128,7 @@ func m3PaintTextFieldContent(canvas widget.Canvas, st *textfield.PaintState, col canvas.DrawRect(st.SelectionRect, colors.SelectionBg) } - canvas.DrawText(st.DisplayText, st.ContentRect, fontSize, textColor, false, m3TFTextAlignLeft) + canvas.DrawText(st.DisplayText, st.TextRect, fontSize, textColor, false, m3TFTextAlignLeft) } // m3PaintTextFieldCursorFromState draws the cursor using pre-computed CursorRect. From 757767be148ec164744070e2d874ed06a582483a Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 19:09:10 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(textfield):=20cursor-scroll=20sync=20?= =?UTF-8?q?=E2=80=94=20Flutter/Qt=20coordinate=20space=20pattern=20(#211,?= =?UTF-8?q?=20#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: CursorX clamped to scrolledRect.Max.X instead of letting PushClip handle visibility. Cursor computed in text-local coords now, scrollOffsetX applied uniformly to cursor/selection/text (Flutter _paintOffset, Qt topLeft pattern). Removed CursorX clamp. --- core/textfield/widget.go | 18 +++++++++++------- internal/textmetrics/textmetrics.go | 3 --- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/core/textfield/widget.go b/core/textfield/widget.go index 589a86a..ce5cfea 100644 --- a/core/textfield/widget.go +++ b/core/textfield/widget.go @@ -160,9 +160,8 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { // Ensure cursor is visible within the content rect (adjusts scrollOffsetX). w.ensureCursorVisible(tm, contentRect, displayText) - // Create a scrolled content rect for text/cursor/selection positioning. - // The scrolled rect shifts the text origin by scrollOffsetX while the - // clip rect (ContentRect) stays at the original position. + // TextRect: text rendering area shifted by scroll offset. + // ContentRect stays unshifted for clipping (PushClip). scrolledRect := contentRect scrolledRect.Min.X += w.scrollOffsetX scrolledRect.Max.X += w.scrollOffsetX @@ -173,18 +172,23 @@ func (w *Widget) Draw(_ widget.Context, canvas widget.Canvas) { w.cachedDisplayText = displayText w.cachedFontSize = fontSize - // Compute cursor rect (if applicable) using the scrolled content rect. + // Flutter/Qt pattern: compute cursor/selection in UNSHIFTED content rect + // (text-local coordinates), then apply scrollOffsetX uniformly. + // This ensures cursor and text share the same offset — they never drift apart. showCursor := focused && !disabled && !hasSelection var cursorRect geometry.Rect if showCursor { - cursorRect = tm.CursorRect(scrolledRect, displayText, w.sel.cursor, cw) + cursorRect = tm.CursorRect(contentRect, displayText, w.sel.cursor, cw) + cursorRect.Min.X += w.scrollOffsetX + cursorRect.Max.X += w.scrollOffsetX } - // Compute selection rect (if applicable) using the scrolled content rect. showSelection := hasSelection var selectionRect geometry.Rect if showSelection { - selectionRect = tm.SelectionRect(scrolledRect, displayText, w.sel.anchor, w.sel.cursor) + selectionRect = tm.SelectionRect(contentRect, displayText, w.sel.anchor, w.sel.cursor) + selectionRect.Min.X += w.scrollOffsetX + selectionRect.Max.X += w.scrollOffsetX } w.painter.PaintTextField(canvas, &PaintState{ diff --git a/internal/textmetrics/textmetrics.go b/internal/textmetrics/textmetrics.go index 8540201..0f8037f 100644 --- a/internal/textmetrics/textmetrics.go +++ b/internal/textmetrics/textmetrics.go @@ -39,9 +39,6 @@ func (m *Metrics) CursorX(contentRect geometry.Rect, displayText string, runePos } textBefore := string(runes[:runePos]) x := baseX + m.Canvas.MeasureText(textBefore, m.FontSize, false) - if x > contentRect.Max.X { - x = contentRect.Max.X - } return x } From 0c3a73d0b05918571734439b433a400eb98d126f Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 19:18:08 +0300 Subject: [PATCH 5/6] chore: remove debug logging from textfield scroll --- core/textfield/widget.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/textfield/widget.go b/core/textfield/widget.go index ce5cfea..6766aad 100644 --- a/core/textfield/widget.go +++ b/core/textfield/widget.go @@ -1,8 +1,6 @@ package textfield import ( - "fmt" - "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" "github.com/gogpu/ui/internal/textmetrics" @@ -254,9 +252,6 @@ func (w *Widget) ensureCursorVisible(tm *textmetrics.Metrics, contentRect geomet // Apply current scroll offset to get the visual cursor position. visualCursorX := cursorRelative + w.scrollOffsetX - fmt.Printf("[SCROLL] cursor=%d cursorRel=%.1f scrollX=%.1f visualX=%.1f contentW=%.1f textW=%.1f\n", - w.sel.cursor, cursorRelative, w.scrollOffsetX, visualCursorX, contentWidth, fullTextWidth) - // If cursor is past the right edge, scroll left to reveal it. if visualCursorX > contentWidth-scrollMargin { w.scrollOffsetX = contentWidth - scrollMargin - cursorRelative From 8a933760d493745cb2b6491e6171c61bcd5c2937 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 6 Aug 2026 20:30:04 +0300 Subject: [PATCH 6/6] ci: trigger rerun