exp/textinput: add Field.ChangedAt for change detection

ChangedAt returns the time of the most recent observable state change —
text, selection, focus, or composition. Useful for cache invalidation,
autosave throttling, idle detection, and similar change-detection tasks.

The value is strictly monotonically increasing: back-to-back mutations
on coarse-clock platforms are clamped forward by 1ns so each mutation
yields a unique timestamp usable as a cache key.

No-op mutations (SetSelection to the current selection, empty
ReplaceText over a zero-width range, Focus/Blur that don't change focus,
Undo/Redo with nothing to apply) do not advance ChangedAt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hajime Hoshi
2026-04-19 15:01:18 +09:00
parent 95fce17647
commit 1a4c38c37d
2 changed files with 357 additions and 7 deletions
+73 -7
View File
@@ -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()
}
+284
View File
@@ -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])
}
}
}