mirror of
https://github.com/hajimehoshi/ebiten.git
synced 2026-04-22 15:57:15 +08:00
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:
+73
-7
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user