exp/textinput: clear queued states when text field is unfocused

The session's state queue buffers input events that arrive between
a commit-end and the next session start, which is needed when multiple
keys are pressed simultaneously (#3382). However, when a text field
was unfocused, keystroke events continued to be queued by the platform
layer and were replayed when a field was next focused, causing
unexpected text to appear.

Clear the queued states in Field.cleanUp(), which is called when a
field is blurred. This preserves the queue for simultaneous keypresses
within a focused field while discarding stale input from unfocused
periods.

Also refactor textInput to move the session field and theTextInput
variable to the cross-platform textinput.go, with each platform
defining only a textInputImpl with platform-specific fields.

Updates #3382
Closes #3429

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hajime Hoshi
2026-04-04 02:50:20 +09:00
parent 22b1889f7f
commit db24fb7c6b
6 changed files with 22 additions and 20 deletions
+2
View File
@@ -290,6 +290,8 @@ func (f *Field) cleanUp() {
f.end = nil
f.state = textInputState{}
}
theTextInput.session.clearQueue()
}
// Selection returns the current selection range in bytes.
+16
View File
@@ -117,6 +117,13 @@ func convertByteCountToUTF16Count(text string, c int) int {
return -1
}
type textInput struct {
textInputImpl
session session
}
var theTextInput textInput
type session struct {
ch chan textInputState
done chan struct{}
@@ -184,6 +191,15 @@ func (s *session) doSend(state textInputState) {
}
}
// clearQueue clears queued states.
// This should be called when the text field is unfocused
// so that the queued states are not flushed when the next session starts (#3429).
func (s *session) clearQueue() {
s.m.Lock()
defer s.m.Unlock()
s.queuedStates = s.queuedStates[:0]
}
func (s *session) flushStateQueue() {
for _, st := range s.queuedStates {
s.doSend(st)
+1 -6
View File
@@ -25,12 +25,7 @@ import (
"github.com/hajimehoshi/ebiten/v2"
)
type textInput struct {
// session must be accessed from the main thread.
session session
}
var theTextInput textInput
type textInputImpl struct{}
func (t *textInput) Start(bounds image.Rectangle) (<-chan textInputState, func()) {
var ch <-chan textInputState
+1 -5
View File
@@ -34,14 +34,10 @@ func init() {
theTextInput.init()
}
type textInput struct {
type textInputImpl struct {
textareaElement js.Value
session session
}
var theTextInput textInput
func (t *textInput) init() {
t.textareaElement = document.Call("createElement", "textarea")
t.textareaElement.Set("id", "ebitengine-textinput")
+1 -4
View File
@@ -22,14 +22,11 @@ import (
"github.com/hajimehoshi/ebiten/v2"
)
type textInput struct {
session session
type textInputImpl struct {
rs []rune
lastTick int64
}
var theTextInput textInput
func (t *textInput) Start(bounds image.Rectangle) (<-chan textInputState, func()) {
// AppendInputChars is updated only when the tick is updated.
// If the tick is not updated, return nil immediately.
+1 -5
View File
@@ -27,9 +27,7 @@ import (
"github.com/hajimehoshi/ebiten/v2/internal/ui"
)
type textInput struct {
session session
type textInputImpl struct {
origWndProc uintptr
wndProcCallback uintptr
window windows.HWND
@@ -42,8 +40,6 @@ type textInput struct {
err error
}
var theTextInput textInput
func (t *textInput) Start(bounds image.Rectangle) (<-chan textInputState, func()) {
if microsoftgdk.IsXbox() {
return nil, nil