From 66b4d4e12de20e6f7bdda3b5eaaad7460a5adfd0 Mon Sep 17 00:00:00 2001 From: Mike Nye Date: Fri, 10 Apr 2026 10:05:23 +0800 Subject: [PATCH 1/2] fix: place text field caret at end of preloaded text --- textfield.go | 36 +++++++++++++++++++++----- textfield_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 textfield_test.go diff --git a/textfield.go b/textfield.go index 3268fec..e14e6db 100644 --- a/textfield.go +++ b/textfield.go @@ -11,6 +11,7 @@ import ( "unicode/utf8" "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/exp/textinput" "github.com/hajimehoshi/ebiten/v2/inpututil" ) @@ -41,8 +42,12 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl f := c.currentContainer().textInputTextField(id, true) if c.focus == id { + // A freshly focused text input field still has its own cursor/selection state. + // Seed that state from the bound string before reading input so typing starts + // after any existing text instead of inserting at the beginning. + focusTextInputField(f, *buf) + // handle text input - f.Focus() x := bounds.Min.X + c.style().padding + textWidth(*buf) y := bounds.Min.Y + lineHeight() handled, err := f.HandleInput(x, y) @@ -58,7 +63,7 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(*buf) > 0 { _, size := utf8.DecodeLastRuneInString(*buf) *buf = (*buf)[:len(*buf)-size] - f.SetTextAndSelection(*buf, len(*buf), len(*buf)) + setTextInputFieldValue(f, *buf) } if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { e = &eventHandler{} @@ -66,7 +71,9 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl } } else { if *buf != f.Text() { - f.SetTextAndSelection(*buf, len(*buf), len(*buf)) + // Keep the cached text-input object in sync while it is unfocused so the + // next focus starts from the latest value and with the caret at the end. + setTextInputFieldValue(f, *buf) } if wasFocused { e = &eventHandler{} @@ -100,13 +107,30 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl }) } +func focusTextInputField(f *textinput.Field, value string) { + // Focus() does not rewrite the field's text or selection. If this field is being + // focused for the first time, its selection is still 0,0, so copy in the current + // value first and move the caret to the end. + if !f.IsFocused() && value != f.Text() { + setTextInputFieldValue(f, value) + } + f.Focus() +} + +func setTextInputFieldValue(f *textinput.Field, value string) { + // Treat programmatic value changes the same way a user expects to keep typing: + // after loading text, place the caret at the end ready for appending. + f.SetTextAndSelection(value, len(value), len(value)) +} + // SetTextFieldValue sets the value of the current text field. +// The caret is moved to the end of the new text. // // If the last widget is not a text field, this function does nothing. func (c *Context) SetTextFieldValue(value string) { _ = c.wrapEventHandlerAndError(func() (EventHandler, error) { if f := c.currentContainer().textInputTextField(c.currentID, false); f != nil { - f.SetTextAndSelection(value, 0, 0) + setTextInputFieldValue(f, value) } return nil, nil }) @@ -201,7 +225,7 @@ func (c *Context) numberField(value *int, step int, idPart string, opt option) ( if updated { buf := fmt.Sprintf("%d", *value) if f := c.currentContainer().textInputTextField(id, false); f != nil { - f.SetTextAndSelection(buf, len(buf), len(buf)) + setTextInputFieldValue(f, buf) } e = &eventHandler{} } @@ -277,7 +301,7 @@ func (c *Context) numberFieldF(value *float64, step float64, digits int, idPart if updated { buf := formatNumber(*value, digits) if f := c.currentContainer().textInputTextField(id, false); f != nil { - f.SetTextAndSelection(buf, len(buf), len(buf)) + setTextInputFieldValue(f, buf) } e = &eventHandler{} } diff --git a/textfield_test.go b/textfield_test.go new file mode 100644 index 0000000..93b2608 --- /dev/null +++ b/textfield_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package debugui + +import ( + "testing" + + "github.com/hajimehoshi/ebiten/v2/exp/textinput" +) + +func TestFocusTextInputFieldInitializesTextAndCaretAtEnd(t *testing.T) { + var f textinput.Field + t.Cleanup(f.Blur) + + // This is the original regression: focusing a field with preloaded text must + // also initialize the internal selection so the next typed character appends. + focusTextInputField(&f, "hello") + + if got, want := f.Text(), "hello"; got != want { + t.Fatalf("text = %q, want %q", got, want) + } + if !f.IsFocused() { + t.Fatal("field is not focused") + } + + start, end := f.Selection() + if got, want := start, len("hello"); got != want { + t.Fatalf("selection start = %d, want %d", got, want) + } + if got, want := end, len("hello"); got != want { + t.Fatalf("selection end = %d, want %d", got, want) + } +} + +func TestSetTextFieldValueMovesCaretToEnd(t *testing.T) { + var c Context + cnt := &container{} + c.containerStack = []*container{cnt} + + id := widgetID{}.push("field") + c.currentID = id + cnt.textInputTextField(id, true) + + // SetTextFieldValue is used to replace the visible contents, so it must leave + // the hidden caret state at the end of the new text as well. + c.SetTextFieldValue("loaded") + + f := cnt.textInputTextField(id, false) + if f == nil { + t.Fatal("field was not created") + } + if got, want := f.Text(), "loaded"; got != want { + t.Fatalf("text = %q, want %q", got, want) + } + + start, end := f.Selection() + if got, want := start, len("loaded"); got != want { + t.Fatalf("selection start = %d, want %d", got, want) + } + if got, want := end, len("loaded"); got != want { + t.Fatalf("selection end = %d, want %d", got, want) + } +} From 5e7a0656cd1d4daf3d2cac27bedcab149fe8ee56 Mon Sep 17 00:00:00 2001 From: Mike Nye Date: Fri, 10 Apr 2026 10:05:23 +0800 Subject: [PATCH 2/2] fix: place text field caret at end of preloaded text --- textfield.go | 41 +++++++++++++++++++---- textfield_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 textfield_test.go diff --git a/textfield.go b/textfield.go index 3268fec..c4d7913 100644 --- a/textfield.go +++ b/textfield.go @@ -11,6 +11,7 @@ import ( "unicode/utf8" "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/exp/textinput" "github.com/hajimehoshi/ebiten/v2/inpututil" ) @@ -41,8 +42,12 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl f := c.currentContainer().textInputTextField(id, true) if c.focus == id { + // A freshly focused text input field still has its own cursor/selection state. + // Seed that state from the bound string before reading input so typing starts + // after any existing text instead of inserting at the beginning. + focusTextInputField(f, *buf) + // handle text input - f.Focus() x := bounds.Min.X + c.style().padding + textWidth(*buf) y := bounds.Min.Y + lineHeight() handled, err := f.HandleInput(x, y) @@ -58,7 +63,7 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(*buf) > 0 { _, size := utf8.DecodeLastRuneInString(*buf) *buf = (*buf)[:len(*buf)-size] - f.SetTextAndSelection(*buf, len(*buf), len(*buf)) + setTextInputFieldValue(f, *buf) } if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { e = &eventHandler{} @@ -66,7 +71,9 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl } } else { if *buf != f.Text() { - f.SetTextAndSelection(*buf, len(*buf), len(*buf)) + // Keep the cached text-input object in sync while it is unfocused so the + // next focus starts from the latest value and with the caret at the end. + setTextInputFieldValue(f, *buf) } if wasFocused { e = &eventHandler{} @@ -100,13 +107,35 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl }) } +func focusTextInputField(f *textinput.Field, value string) { + // Focus() does not rewrite the field's text or selection. If this field is being + // focused for the first time, its selection is still 0,0, so copy in the current + // value first and move the caret to the end. + // + // Reset the selection on every unfocused->focused transition, even when the + // text already matches. The cached textinput.Field can survive across window + // reopenings, and in that case it can keep an old caret position from an + // earlier edit session. + if !f.IsFocused() { + setTextInputFieldValue(f, value) + } + f.Focus() +} + +func setTextInputFieldValue(f *textinput.Field, value string) { + // Treat programmatic value changes the same way a user expects to keep typing: + // after loading text, place the caret at the end ready for appending. + f.SetTextAndSelection(value, len(value), len(value)) +} + // SetTextFieldValue sets the value of the current text field. +// The caret is moved to the end of the new text. // // If the last widget is not a text field, this function does nothing. func (c *Context) SetTextFieldValue(value string) { _ = c.wrapEventHandlerAndError(func() (EventHandler, error) { if f := c.currentContainer().textInputTextField(c.currentID, false); f != nil { - f.SetTextAndSelection(value, 0, 0) + setTextInputFieldValue(f, value) } return nil, nil }) @@ -201,7 +230,7 @@ func (c *Context) numberField(value *int, step int, idPart string, opt option) ( if updated { buf := fmt.Sprintf("%d", *value) if f := c.currentContainer().textInputTextField(id, false); f != nil { - f.SetTextAndSelection(buf, len(buf), len(buf)) + setTextInputFieldValue(f, buf) } e = &eventHandler{} } @@ -277,7 +306,7 @@ func (c *Context) numberFieldF(value *float64, step float64, digits int, idPart if updated { buf := formatNumber(*value, digits) if f := c.currentContainer().textInputTextField(id, false); f != nil { - f.SetTextAndSelection(buf, len(buf), len(buf)) + setTextInputFieldValue(f, buf) } e = &eventHandler{} } diff --git a/textfield_test.go b/textfield_test.go new file mode 100644 index 0000000..ff06bc3 --- /dev/null +++ b/textfield_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package debugui + +import ( + "testing" + + "github.com/hajimehoshi/ebiten/v2/exp/textinput" +) + +func TestFocusTextInputFieldInitializesTextAndCaretAtEnd(t *testing.T) { + var f textinput.Field + t.Cleanup(f.Blur) + + // This is the original regression: focusing a field with preloaded text must + // also initialize the internal selection so the next typed character appends. + focusTextInputField(&f, "hello") + + if got, want := f.Text(), "hello"; got != want { + t.Fatalf("text = %q, want %q", got, want) + } + if !f.IsFocused() { + t.Fatal("field is not focused") + } + + start, end := f.Selection() + if got, want := start, len("hello"); got != want { + t.Fatalf("selection start = %d, want %d", got, want) + } + if got, want := end, len("hello"); got != want { + t.Fatalf("selection end = %d, want %d", got, want) + } +} + +func TestFocusTextInputFieldMovesCaretToEndWhenTextAlreadyMatches(t *testing.T) { + var f textinput.Field + t.Cleanup(f.Blur) + + f.SetTextAndSelection("hello", 0, 0) + + // Reopening a window can reuse the same cached field with the same text but + // an old selection. Focusing it again should still place the caret at the end. + focusTextInputField(&f, "hello") + + start, end := f.Selection() + if got, want := start, len("hello"); got != want { + t.Fatalf("selection start = %d, want %d", got, want) + } + if got, want := end, len("hello"); got != want { + t.Fatalf("selection end = %d, want %d", got, want) + } +} + +func TestSetTextFieldValueMovesCaretToEnd(t *testing.T) { + var c Context + cnt := &container{} + c.containerStack = []*container{cnt} + + id := widgetID{}.push("field") + c.currentID = id + cnt.textInputTextField(id, true) + + // SetTextFieldValue is used to replace the visible contents, so it must leave + // the hidden caret state at the end of the new text as well. + c.SetTextFieldValue("loaded") + + f := cnt.textInputTextField(id, false) + if f == nil { + t.Fatal("field was not created") + } + if got, want := f.Text(), "loaded"; got != want { + t.Fatalf("text = %q, want %q", got, want) + } + + start, end := f.Selection() + if got, want := start, len("loaded"); got != want { + t.Fatalf("selection start = %d, want %d", got, want) + } + if got, want := end, len("loaded"); got != want { + t.Fatalf("selection end = %d, want %d", got, want) + } +}