Skip to content

Commit 5e7a065

Browse files
committed
fix: place text field caret at end of preloaded text
1 parent fe9b306 commit 5e7a065

2 files changed

Lines changed: 118 additions & 6 deletions

File tree

textfield.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"unicode/utf8"
1212

1313
"github.com/hajimehoshi/ebiten/v2"
14+
"github.com/hajimehoshi/ebiten/v2/exp/textinput"
1415
"github.com/hajimehoshi/ebiten/v2/inpututil"
1516
)
1617

@@ -41,8 +42,12 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl
4142

4243
f := c.currentContainer().textInputTextField(id, true)
4344
if c.focus == id {
45+
// A freshly focused text input field still has its own cursor/selection state.
46+
// Seed that state from the bound string before reading input so typing starts
47+
// after any existing text instead of inserting at the beginning.
48+
focusTextInputField(f, *buf)
49+
4450
// handle text input
45-
f.Focus()
4651
x := bounds.Min.X + c.style().padding + textWidth(*buf)
4752
y := bounds.Min.Y + lineHeight()
4853
handled, err := f.HandleInput(x, y)
@@ -58,15 +63,17 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl
5863
if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(*buf) > 0 {
5964
_, size := utf8.DecodeLastRuneInString(*buf)
6065
*buf = (*buf)[:len(*buf)-size]
61-
f.SetTextAndSelection(*buf, len(*buf), len(*buf))
66+
setTextInputFieldValue(f, *buf)
6267
}
6368
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
6469
e = &eventHandler{}
6570
}
6671
}
6772
} else {
6873
if *buf != f.Text() {
69-
f.SetTextAndSelection(*buf, len(*buf), len(*buf))
74+
// Keep the cached text-input object in sync while it is unfocused so the
75+
// next focus starts from the latest value and with the caret at the end.
76+
setTextInputFieldValue(f, *buf)
7077
}
7178
if wasFocused {
7279
e = &eventHandler{}
@@ -100,13 +107,35 @@ func (c *Context) textFieldRaw(buf *string, id widgetID, opt option) (EventHandl
100107
})
101108
}
102109

110+
func focusTextInputField(f *textinput.Field, value string) {
111+
// Focus() does not rewrite the field's text or selection. If this field is being
112+
// focused for the first time, its selection is still 0,0, so copy in the current
113+
// value first and move the caret to the end.
114+
//
115+
// Reset the selection on every unfocused->focused transition, even when the
116+
// text already matches. The cached textinput.Field can survive across window
117+
// reopenings, and in that case it can keep an old caret position from an
118+
// earlier edit session.
119+
if !f.IsFocused() {
120+
setTextInputFieldValue(f, value)
121+
}
122+
f.Focus()
123+
}
124+
125+
func setTextInputFieldValue(f *textinput.Field, value string) {
126+
// Treat programmatic value changes the same way a user expects to keep typing:
127+
// after loading text, place the caret at the end ready for appending.
128+
f.SetTextAndSelection(value, len(value), len(value))
129+
}
130+
103131
// SetTextFieldValue sets the value of the current text field.
132+
// The caret is moved to the end of the new text.
104133
//
105134
// If the last widget is not a text field, this function does nothing.
106135
func (c *Context) SetTextFieldValue(value string) {
107136
_ = c.wrapEventHandlerAndError(func() (EventHandler, error) {
108137
if f := c.currentContainer().textInputTextField(c.currentID, false); f != nil {
109-
f.SetTextAndSelection(value, 0, 0)
138+
setTextInputFieldValue(f, value)
110139
}
111140
return nil, nil
112141
})
@@ -201,7 +230,7 @@ func (c *Context) numberField(value *int, step int, idPart string, opt option) (
201230
if updated {
202231
buf := fmt.Sprintf("%d", *value)
203232
if f := c.currentContainer().textInputTextField(id, false); f != nil {
204-
f.SetTextAndSelection(buf, len(buf), len(buf))
233+
setTextInputFieldValue(f, buf)
205234
}
206235
e = &eventHandler{}
207236
}
@@ -277,7 +306,7 @@ func (c *Context) numberFieldF(value *float64, step float64, digits int, idPart
277306
if updated {
278307
buf := formatNumber(*value, digits)
279308
if f := c.currentContainer().textInputTextField(id, false); f != nil {
280-
f.SetTextAndSelection(buf, len(buf), len(buf))
309+
setTextInputFieldValue(f, buf)
281310
}
282311
e = &eventHandler{}
283312
}

textfield_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2026 The Ebitengine Authors
3+
4+
package debugui
5+
6+
import (
7+
"testing"
8+
9+
"github.com/hajimehoshi/ebiten/v2/exp/textinput"
10+
)
11+
12+
func TestFocusTextInputFieldInitializesTextAndCaretAtEnd(t *testing.T) {
13+
var f textinput.Field
14+
t.Cleanup(f.Blur)
15+
16+
// This is the original regression: focusing a field with preloaded text must
17+
// also initialize the internal selection so the next typed character appends.
18+
focusTextInputField(&f, "hello")
19+
20+
if got, want := f.Text(), "hello"; got != want {
21+
t.Fatalf("text = %q, want %q", got, want)
22+
}
23+
if !f.IsFocused() {
24+
t.Fatal("field is not focused")
25+
}
26+
27+
start, end := f.Selection()
28+
if got, want := start, len("hello"); got != want {
29+
t.Fatalf("selection start = %d, want %d", got, want)
30+
}
31+
if got, want := end, len("hello"); got != want {
32+
t.Fatalf("selection end = %d, want %d", got, want)
33+
}
34+
}
35+
36+
func TestFocusTextInputFieldMovesCaretToEndWhenTextAlreadyMatches(t *testing.T) {
37+
var f textinput.Field
38+
t.Cleanup(f.Blur)
39+
40+
f.SetTextAndSelection("hello", 0, 0)
41+
42+
// Reopening a window can reuse the same cached field with the same text but
43+
// an old selection. Focusing it again should still place the caret at the end.
44+
focusTextInputField(&f, "hello")
45+
46+
start, end := f.Selection()
47+
if got, want := start, len("hello"); got != want {
48+
t.Fatalf("selection start = %d, want %d", got, want)
49+
}
50+
if got, want := end, len("hello"); got != want {
51+
t.Fatalf("selection end = %d, want %d", got, want)
52+
}
53+
}
54+
55+
func TestSetTextFieldValueMovesCaretToEnd(t *testing.T) {
56+
var c Context
57+
cnt := &container{}
58+
c.containerStack = []*container{cnt}
59+
60+
id := widgetID{}.push("field")
61+
c.currentID = id
62+
cnt.textInputTextField(id, true)
63+
64+
// SetTextFieldValue is used to replace the visible contents, so it must leave
65+
// the hidden caret state at the end of the new text as well.
66+
c.SetTextFieldValue("loaded")
67+
68+
f := cnt.textInputTextField(id, false)
69+
if f == nil {
70+
t.Fatal("field was not created")
71+
}
72+
if got, want := f.Text(), "loaded"; got != want {
73+
t.Fatalf("text = %q, want %q", got, want)
74+
}
75+
76+
start, end := f.Selection()
77+
if got, want := start, len("loaded"); got != want {
78+
t.Fatalf("selection start = %d, want %d", got, want)
79+
}
80+
if got, want := end, len("loaded"); got != want {
81+
t.Fatalf("selection end = %d, want %d", got, want)
82+
}
83+
}

0 commit comments

Comments
 (0)