diff --git a/exp/textinput/field.go b/exp/textinput/field.go index 9c872f4bb..d2fc8b950 100644 --- a/exp/textinput/field.go +++ b/exp/textinput/field.go @@ -19,6 +19,7 @@ import ( "io" "strings" "sync" + "time" "github.com/hajimehoshi/ebiten/v2/internal/hook" ) @@ -28,11 +29,20 @@ var ( theFocusedFieldM sync.Mutex ) +// focusField makes f the focused field. Any previously focused field is cleaned up +// and its ChangedAt is bumped to reflect the focus loss. func focusField(f *Field) { var origField *Field + var focused bool + // All ChangedAt bumps happen here, outside the mutex. defer func() { + if focused { + f.bumpChangedAt() + } if origField != nil { origField.cleanUp() + // The previously focused field just lost focus, which is observable via IsFocused. + origField.bumpChangedAt() } }() @@ -43,13 +53,17 @@ func focusField(f *Field) { } origField = theFocusedField theFocusedField = f + focused = true } +// blurField removes the focus from f. If f was focused, its ChangedAt is bumped. func blurField(f *Field) { var origField *Field + // All ChangedAt bumps happen here, outside the mutex. origField is always f when set. defer func() { if origField != nil { origField.cleanUp() + origField.bumpChangedAt() } }() @@ -116,6 +130,42 @@ type Field struct { end func() state textInputState err error + + changedAt time.Time +} + +// ChangedAt returns the time of the most recent state-changing mutation to this Field. +// +// ChangedAt is monotonically non-decreasing across mutations of this Field, suitable for +// cache invalidation and similar change-detection use cases (autosave throttling, idle +// detection, debouncing, etc.). The zero value indicates that no mutation has occurred. +func (f *Field) ChangedAt() time.Time { + return f.changedAt +} + +// bumpChangedAt advances changedAt to the current time, guaranteeing strict monotonic +// increase across back-to-back synchronous calls even on platforms with a coarse clock. +func (f *Field) bumpChangedAt() { + now := time.Now() + if !now.After(f.changedAt) { + now = f.changedAt.Add(time.Nanosecond) + } + f.changedAt = now +} + +// setState assigns s to f.state, bumping changedAt when the visible composition state changes. +// +// Only the fields observable through the public API (Text and the composition selection) +// are compared. The remaining fields (Committed/Delete*/Error) are transient IME signaling, +// not part of the observable state this function is meant to track. +func (f *Field) setState(s textInputState) { + changed := f.state.Text != s.Text || + f.state.CompositionSelectionStartInBytes != s.CompositionSelectionStartInBytes || + f.state.CompositionSelectionEndInBytes != s.CompositionSelectionEndInBytes + f.state = s + if changed { + f.bumpChangedAt() + } } // SetBounds sets the bounds used for IME window positioning. @@ -203,12 +253,12 @@ func (f *Field) handleInput() (handled bool, err error) { if !ok { f.ch = nil f.end = nil - f.state = textInputState{} + f.setState(textInputState{}) break readchar } if state.Committed && state.Text == "\x7f" { // DEL should not modify the text (#3212). - f.state = textInputState{} + f.setState(textInputState{}) continue } handled = true @@ -216,7 +266,7 @@ func (f *Field) handleInput() (handled bool, err error) { f.commit(state) continue } - f.state = state + f.setState(state) default: break readchar } @@ -240,6 +290,7 @@ func (f *Field) commit(state textInputState) { f.selectionStartInBytes = start + len(state.Text) f.selectionEndInBytes = f.selectionStartInBytes f.state = textInputState{} + f.bumpChangedAt() } // Focus focuses the field. @@ -277,7 +328,7 @@ func (f *Field) cleanUp() { if ok && state.Committed { f.commit(state) } else { - f.state = state + f.setState(state) } default: break @@ -288,7 +339,7 @@ func (f *Field) cleanUp() { f.end() f.ch = nil f.end = nil - f.state = textInputState{} + f.setState(textInputState{}) } theTextInput.session.clearQueue() @@ -313,8 +364,14 @@ func (f *Field) CompositionSelection() (startInBytes, endInBytes int, ok bool) { func (f *Field) SetSelection(startInBytes, endInBytes int) { f.cleanUp() l := f.pieceTable.Len() - f.selectionStartInBytes = min(max(startInBytes, 0), l) - f.selectionEndInBytes = min(max(endInBytes, 0), l) + newStart := min(max(startInBytes, 0), l) + newEnd := min(max(endInBytes, 0), l) + if newStart == f.selectionStartInBytes && newEnd == f.selectionEndInBytes { + return + } + f.selectionStartInBytes = newStart + f.selectionEndInBytes = newEnd + f.bumpChangedAt() } // Text returns the current text. @@ -368,6 +425,7 @@ func (f *Field) ResetText(text string) { f.pieceTable.reset(text) f.selectionStartInBytes = 0 f.selectionEndInBytes = 0 + f.bumpChangedAt() } // UncommittedTextLengthInBytes returns the compositing text length in bytes when the field is focused and the text is editing. @@ -388,15 +446,21 @@ func (f *Field) SetTextAndSelection(text string, selectionStartInBytes, selectio f.pieceTable.replace(text, 0, l) f.selectionStartInBytes = min(max(selectionStartInBytes, 0), l) f.selectionEndInBytes = min(max(selectionEndInBytes, 0), l) + f.bumpChangedAt() } // ReplaceText replaces the text at the specified range and updates the selection range. // This operation is added to the undo history. func (f *Field) ReplaceText(text string, startInBytes, endInBytes int) { f.cleanUp() + if text == "" && startInBytes == endInBytes { + // Empty replacement over a zero-width range is observably a no-op. + return + } f.pieceTable.replace(text, startInBytes, endInBytes) f.selectionStartInBytes = startInBytes + len(text) f.selectionEndInBytes = f.selectionStartInBytes + f.bumpChangedAt() } // ReplaceTextAtSelection replaces the text at the selection range and updates the selection range. @@ -425,6 +489,7 @@ func (f *Field) Undo() { } f.selectionStartInBytes = start f.selectionEndInBytes = end + f.bumpChangedAt() } // Redo redoes the last undone operation. @@ -437,4 +502,5 @@ func (f *Field) Redo() { } f.selectionStartInBytes = start f.selectionEndInBytes = end + f.bumpChangedAt() } diff --git a/exp/textinput/field_test.go b/exp/textinput/field_test.go new file mode 100644 index 000000000..6ba31efdf --- /dev/null +++ b/exp/textinput/field_test.go @@ -0,0 +1,284 @@ +// Copyright 2026 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package textinput_test + +import ( + "strings" + "testing" + "time" + + "github.com/hajimehoshi/ebiten/v2/exp/textinput" +) + +func TestFieldChangedAtZeroValue(t *testing.T) { + var f textinput.Field + if got := f.ChangedAt(); !got.IsZero() { + t.Errorf("fresh Field: ChangedAt() = %v, want zero value", got) + } +} + +func TestFieldChangedAtAdvancesOnMutations(t *testing.T) { + testCases := []struct { + name string + setup func(*textinput.Field) + mutate func(*textinput.Field) + }{ + { + name: "ResetText", + setup: func(f *textinput.Field) {}, + mutate: func(f *textinput.Field) { + f.ResetText("hello") + }, + }, + { + name: "SetTextAndSelection", + setup: func(f *textinput.Field) {}, + mutate: func(f *textinput.Field) { + f.SetTextAndSelection("abc", 1, 2) + }, + }, + { + name: "ReplaceText", + setup: func(f *textinput.Field) { + f.ResetText("hello") + }, + mutate: func(f *textinput.Field) { + f.ReplaceText("X", 0, 5) + }, + }, + { + name: "ReplaceTextAtSelection", + setup: func(f *textinput.Field) { + f.ResetText("hello") + f.SetSelection(0, 5) + }, + mutate: func(f *textinput.Field) { + f.ReplaceTextAtSelection("Y") + }, + }, + { + name: "SetSelection", + setup: func(f *textinput.Field) { + f.ResetText("hello") + }, + mutate: func(f *textinput.Field) { + f.SetSelection(1, 3) + }, + }, + { + name: "Focus", + setup: func(f *textinput.Field) {}, + mutate: func(f *textinput.Field) { + f.Focus() + }, + }, + { + name: "Blur", + setup: func(f *textinput.Field) { + f.Focus() + }, + mutate: func(f *textinput.Field) { + f.Blur() + }, + }, + { + name: "Undo", + setup: func(f *textinput.Field) { + f.ResetText("hello") + f.ReplaceText("X", 0, 5) + }, + mutate: func(f *textinput.Field) { + f.Undo() + }, + }, + { + name: "Redo", + setup: func(f *textinput.Field) { + f.ResetText("hello") + f.ReplaceText("X", 0, 5) + f.Undo() + }, + mutate: func(f *textinput.Field) { + f.Redo() + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var f textinput.Field + t.Cleanup(func() { f.Blur() }) + tc.setup(&f) + before := f.ChangedAt() + tc.mutate(&f) + after := f.ChangedAt() + if !after.After(before) { + t.Errorf("%s: ChangedAt did not advance: before=%v after=%v", tc.name, before, after) + } + }) + } +} + +func TestFieldChangedAtNoOpDoesNotAdvance(t *testing.T) { + testCases := []struct { + name string + setup func(*textinput.Field) + mutate func(*textinput.Field) + }{ + { + name: "SetSelectionWithCurrentSelection", + setup: func(f *textinput.Field) { + f.ResetText("hello") + f.SetSelection(2, 4) + }, + mutate: func(f *textinput.Field) { + f.SetSelection(2, 4) + }, + }, + { + name: "SetSelectionClampingToCurrent", + setup: func(f *textinput.Field) { + f.ResetText("hello") + }, + mutate: func(f *textinput.Field) { + // (0,0) is already the selection; negatives clamp to (0,0). + f.SetSelection(-5, -5) + }, + }, + { + name: "ReplaceTextEmptyZeroWidth", + setup: func(f *textinput.Field) { + f.ResetText("hello") + f.SetSelection(3, 3) + }, + mutate: func(f *textinput.Field) { + f.ReplaceText("", 3, 3) + }, + }, + { + name: "FocusWhenAlreadyFocused", + setup: func(f *textinput.Field) { + f.Focus() + }, + mutate: func(f *textinput.Field) { + f.Focus() + }, + }, + { + name: "BlurWhenNotFocused", + setup: func(f *textinput.Field) {}, + mutate: func(f *textinput.Field) { + f.Blur() + }, + }, + { + name: "UndoWithEmptyHistory", + setup: func(f *textinput.Field) {}, + mutate: func(f *textinput.Field) { + f.Undo() + }, + }, + { + name: "RedoWithNothingToRedo", + setup: func(f *textinput.Field) { + f.ResetText("hello") + }, + mutate: func(f *textinput.Field) { + f.Redo() + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var f textinput.Field + t.Cleanup(func() { f.Blur() }) + tc.setup(&f) + before := f.ChangedAt() + tc.mutate(&f) + after := f.ChangedAt() + if !after.Equal(before) { + t.Errorf("%s: ChangedAt advanced on no-op: before=%v after=%v", tc.name, before, after) + } + }) + } +} + +func TestFieldChangedAtReadOnlyMethodsDoNotAdvance(t *testing.T) { + var f textinput.Field + t.Cleanup(func() { f.Blur() }) + f.ResetText("hello") + f.SetSelection(1, 3) + f.Focus() + + prior := f.ChangedAt() + + _ = f.Text() + _ = f.TextForRendering() + _ = f.HasText() + _ = f.TextLengthInBytes() + _ = f.IsFocused() + _, _ = f.Selection() + _, _, _ = f.CompositionSelection() + _ = f.CanUndo() + _ = f.CanRedo() + _ = f.UncommittedTextLengthInBytes() + _ = f.Handled() + var b strings.Builder + _ = f.WriteText(&b) + b.Reset() + _ = f.WriteTextForRendering(&b) + + if got := f.ChangedAt(); !got.Equal(prior) { + t.Errorf("read-only methods advanced ChangedAt: before=%v after=%v", prior, got) + } +} + +func TestFieldChangedAtStolenFocusAdvancesPreviousField(t *testing.T) { + var a, b textinput.Field + t.Cleanup(func() { + a.Blur() + b.Blur() + }) + a.Focus() + before := a.ChangedAt() + b.Focus() // steals focus from a + if !a.ChangedAt().After(before) { + t.Errorf("a.ChangedAt did not advance when b stole focus: before=%v after=%v", before, a.ChangedAt()) + } + if a.IsFocused() { + t.Errorf("a should no longer be focused") + } + if !b.IsFocused() { + t.Errorf("b should be focused") + } +} + +func TestFieldChangedAtStrictlyMonotonicUnderTightLoop(t *testing.T) { + var f textinput.Field + t.Cleanup(func() { f.Blur() }) + + const n = 1000 + times := make([]time.Time, n) + for i := range n { + // Insert a character at the current end so each call is a real mutation. + f.ReplaceText("a", i, i) + times[i] = f.ChangedAt() + } + // Strict .After() across the whole sequence implies both ordering and uniqueness. + for i := range n - 1 { + if !times[i+1].After(times[i]) { + t.Errorf("index %d: timestamps not strictly increasing: %v <= %v", i+1, times[i+1], times[i]) + } + } +}