Skip to content
Open
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
41 changes: 35 additions & 6 deletions textfield.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -58,15 +63,17 @@ 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{}
}
}
} 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{}
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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{}
}
Expand Down Expand Up @@ -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{}
}
Expand Down
83 changes: 83 additions & 0 deletions textfield_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading