diff --git a/widget/entry.go b/widget/entry.go index 95fd7bcb7f..1824cc34e7 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -4,7 +4,6 @@ import ( "image/color" "math" "strings" - "time" "unicode" "fyne.io/fyne/v2" @@ -57,6 +56,8 @@ type Entry struct { CursorRow, CursorColumn int OnCursorChanged func() `json:"-"` + cursorAnim *entryCursorAnimation + focused bool text *textProvider placeholder *textProvider @@ -161,7 +162,10 @@ func (e *Entry) CreateRenderer() fyne.WidgetRenderer { box := canvas.NewRectangle(theme.InputBackgroundColor()) line := canvas.NewRectangle(theme.ShadowColor()) + cursor := canvas.NewRectangle(color.Transparent) + cursor.Hide() + e.cursorAnim = newEntryCursorAnimation(cursor) e.content = &entryContent{entry: e} e.scroll = widget.NewScroll(e.content) objects := []fyne.CanvasObject{box, line, e.scroll} @@ -494,7 +498,9 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) { if e.Disabled() { return } - + if e.cursorAnim != nil { + e.cursorAnim.temporaryStop() + } e.propertyLock.RLock() provider := e.textProvider() onSubmitted := e.OnSubmitted @@ -1215,9 +1221,6 @@ type entryContent struct { func (e *entryContent) CreateRenderer() fyne.WidgetRenderer { e.ExtendBaseWidget(e) - cursor := canvas.NewRectangle(color.Transparent) - cursor.Hide() - e.entry.propertyLock.Lock() defer e.entry.propertyLock.Unlock() provider := e.entry.textProvider() @@ -1225,9 +1228,9 @@ func (e *entryContent) CreateRenderer() fyne.WidgetRenderer { if provider.len() != 0 { placeholder.Hide() } - objects := []fyne.CanvasObject{placeholder, provider, cursor} + objects := []fyne.CanvasObject{placeholder, provider, e.entry.cursorAnim.cursor} - r := &entryContentRenderer{cursor, []fyne.CanvasObject{}, nil, objects, + r := &entryContentRenderer{e.entry.cursorAnim.cursor, []fyne.CanvasObject{}, objects, provider, placeholder, e} r.updateScrollDirections() r.Layout(e.size) @@ -1256,17 +1259,16 @@ func (e *entryContent) Dragged(d *fyne.DragEvent) { var _ fyne.WidgetRenderer = (*entryContentRenderer)(nil) type entryContentRenderer struct { - cursor *canvas.Rectangle - selection []fyne.CanvasObject - cursorAnim *fyne.Animation - objects []fyne.CanvasObject + cursor *canvas.Rectangle + selection []fyne.CanvasObject + objects []fyne.CanvasObject provider, placeholder *textProvider content *entryContent } func (r *entryContentRenderer) Destroy() { - r.cursorAnim.Stop() + r.content.entry.cursorAnim.stop() } func (r *entryContentRenderer) Layout(size fyne.Size) { @@ -1316,15 +1318,9 @@ func (r *entryContentRenderer) Refresh() { if focused { r.cursor.Show() - if r.cursorAnim == nil { - r.cursorAnim = makeCursorAnimation(r.cursor) - r.cursorAnim.Start() - } + r.content.entry.cursorAnim.start() } else { - if r.cursorAnim != nil { - r.cursorAnim.Stop() - r.cursorAnim = nil - } + r.content.entry.cursorAnim.stop() r.cursor.Hide() } r.moveCursor() @@ -1572,17 +1568,3 @@ func getTextWhitespaceRegion(row []rune, col int) (int, int) { } return start, end } - -func makeCursorAnimation(cursor *canvas.Rectangle) *fyne.Animation { - cursorOpaque := theme.PrimaryColor() - r, g, b, _ := theme.PrimaryColor().RGBA() - cursorDim := color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 0x16} - anim := canvas.NewColorRGBAAnimation(cursorDim, cursorOpaque, time.Second/2, func(c color.Color) { - cursor.FillColor = c - cursor.Refresh() - }) - anim.RepeatCount = fyne.AnimationRepeatForever - anim.AutoReverse = true - - return anim -} diff --git a/widget/entry_cursor_anim.go b/widget/entry_cursor_anim.go new file mode 100644 index 0000000000..f963c2bbc5 --- /dev/null +++ b/widget/entry_cursor_anim.go @@ -0,0 +1,194 @@ +package widget + +import ( + "image/color" + "sync" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/theme" +) + +// =============================================================== +// Cursor ticker +// =============================================================== + +type cursorTicker struct { + timer *time.Timer + resetChan chan struct{} + duration time.Duration + mockWait func() (reset bool) +} + +func (t *cursorTicker) reset() { + if !t.started() { + return + } + select { + case t.resetChan <- struct{}{}: + default: + } +} + +func (t *cursorTicker) start() { + if t.started() { + return + } + t.resetChan = make(chan struct{}, 1) + t.timer = time.NewTimer(t.duration) +} + +func (t *cursorTicker) started() bool { return t.timer != nil } + +// stop must be called in the same go routine where WaitTick is used. +func (t *cursorTicker) stop() { + if !t.started() { + return + } + if !t.timer.Stop() { + <-t.timer.C + } + t.timer = nil + t.resetChan = nil +} + +func (t *cursorTicker) waitTick() (reset bool) { + if !t.started() { + return + } + if t.mockWait != nil { + return t.mockWait() + } + select { + case <-t.timer.C: + reset = false + t.timer.Stop() + case <-t.resetChan: + reset = true + if !t.timer.Stop() { + <-t.timer.C + } + } + t.timer.Reset(t.duration) + return +} + +// =============================================================== +// Implementation +// =============================================================== + +const cursorInterruptTime = 300 * time.Millisecond + +type cursorState int + +const ( + cursorStateRunning cursorState = iota + cursorStateInterrupted + cursorStateStopped +) + +type entryCursorAnimation struct { + mu *sync.RWMutex + inverted bool + state cursorState + ticker *cursorTicker + cursor *canvas.Rectangle + anim *fyne.Animation +} + +func newEntryCursorAnimation(cursor *canvas.Rectangle) *entryCursorAnimation { + a := &entryCursorAnimation{mu: &sync.RWMutex{}, cursor: cursor} + a.ticker = &cursorTicker{duration: cursorInterruptTime} + a.inverted = false + a.state = cursorStateStopped + return a +} + +func (a *entryCursorAnimation) createAnim(inverted bool) *fyne.Animation { + cursorOpaque := theme.PrimaryColor() + r, g, b, _ := theme.PrimaryColor().RGBA() + cursorDim := color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 0x16} + a.inverted = inverted + start, end := color.Color(cursorDim), color.Color(cursorOpaque) + if inverted { + start, end = end, start + } + anim := canvas.NewColorRGBAAnimation(start, end, time.Second/2, func(c color.Color) { + a.cursor.FillColor = c + a.cursor.Refresh() + }) + anim.RepeatCount = fyne.AnimationRepeatForever + anim.AutoReverse = true + return anim +} + +// Start starts cursor animation. +func (a *entryCursorAnimation) start() { + a.mu.Lock() + defer a.mu.Unlock() + if a.anim != nil || a.ticker.started() || a.state != cursorStateStopped { + return + } + a.anim = a.createAnim(false) + a.state = cursorStateRunning + a.ticker.start() + go func() { + for { + if reset := a.ticker.waitTick(); reset { + continue + } + a.mu.RLock() + interrupted := a.state == cursorStateInterrupted + cancel := a.anim == nil + a.mu.RUnlock() + if cancel { + break + } + if !interrupted { + continue + } + a.mu.Lock() + if a.anim != nil { + a.anim.Start() + } + a.state = cursorStateRunning + a.mu.Unlock() + } + a.mu.Lock() + a.state = cursorStateStopped + a.ticker.stop() + a.mu.Unlock() + }() + a.anim.Start() +} + +// temporaryStop temporarily stops the cursor by "cursorInterruptTime". +func (a *entryCursorAnimation) temporaryStop() { + a.mu.Lock() + defer a.mu.Unlock() + if a.anim == nil || !a.ticker.started() { + return + } + a.ticker.reset() + a.anim.Stop() + if !a.inverted { + a.anim = a.createAnim(true) + } + if a.state == cursorStateInterrupted { + return + } + a.state = cursorStateInterrupted + a.cursor.FillColor = theme.PrimaryColor() + a.cursor.Refresh() +} + +// stop stops cursor animation. +func (a *entryCursorAnimation) stop() { + a.mu.Lock() + defer a.mu.Unlock() + if a.anim != nil { + a.anim.Stop() + a.anim = nil + } +} diff --git a/widget/entry_cursor_anim_test.go b/widget/entry_cursor_anim_test.go new file mode 100644 index 0000000000..237b1a1d39 --- /dev/null +++ b/widget/entry_cursor_anim_test.go @@ -0,0 +1,103 @@ +package widget + +import ( + "image/color" + "sync" + "testing" + "time" + + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/theme" + "github.com/stretchr/testify/assert" +) + +func TestEntryCursorAnim(t *testing.T) { + cursor := canvas.NewRectangle(color.Black) + a := &entryCursorAnimation{mu: &sync.RWMutex{}, cursor: cursor} + a.inverted = false + a.state = cursorStateStopped + a.ticker = &cursorTicker{} + beforeNextTick := make(chan int) + afterTick := make(chan int) + a.ticker.mockWait = func() (reset bool) { + beforeNextTick <- 1 + <-afterTick + return + } + flushReset := func() { <-a.ticker.resetChan } + + // start animation + a.start() + // unblock waitTick() to start testing + <-beforeNextTick + assert.False(t, a.inverted) + assert.Equal(t, cursorStateRunning, a.state) + assert.NotNil(t, a.anim) + assert.True(t, a.ticker.started()) + + // pass 1 cursorTicker tick + afterTick <- 1 // unblock to execute code below waitTick() + <-beforeNextTick // wait until the code below waitTick was effectively executed + + // entry cursor animation must have the same values as before + assert.False(t, a.inverted) + assert.Equal(t, cursorStateRunning, a.state) + assert.NotNil(t, a.anim) + assert.True(t, a.ticker.started()) + + // now call a TemporaryStop() + go flushReset() + a.temporaryStop() + assert.True(t, a.inverted) + assert.Equal(t, cursorStateInterrupted, a.state) + assert.NotNil(t, a.anim) + assert.True(t, a.ticker.started()) + assert.Equal(t, theme.PrimaryColor(), a.cursor.FillColor) + + // after 1 cursorTicker tick, the animation should start again + afterTick <- 1 // unblock to execute code below waitTick() + <-beforeNextTick // wait until the code below waitTick was effectively executed + assert.True(t, a.inverted) + assert.Equal(t, cursorStateRunning, a.state) + assert.True(t, a.ticker.started()) + assert.NotNil(t, a.anim) + + // after 1 cursorTicker tick, the animation should continue (not interrupted) + afterTick <- 1 // unblock to execute code below waitTick() + <-beforeNextTick // wait until the code below waitTick was effectively executed + assert.True(t, a.inverted) + assert.Equal(t, cursorStateRunning, a.state) + assert.True(t, a.ticker.started()) + assert.NotNil(t, a.anim) + + // temporary stop again + go flushReset() + a.temporaryStop() + assert.True(t, a.inverted) + assert.Equal(t, cursorStateInterrupted, a.state) + assert.NotNil(t, a.anim) + assert.True(t, a.ticker.started()) + assert.Equal(t, theme.PrimaryColor(), a.cursor.FillColor) + + // stop the animation + a.stop() + // unblock to execute code below waitTick() and so it should break the for-loop, and stop + // ticker and animation + afterTick <- 1 + time.Sleep(2 * time.Millisecond) + assert.True(t, a.inverted) + assert.Equal(t, cursorStateStopped, a.state) + assert.Nil(t, a.anim) + assert.False(t, a.ticker.started()) + + // calling a.TemporaryStop() on stopped animation, does not do anything + a.temporaryStop() + assert.True(t, a.inverted) + assert.Equal(t, cursorStateStopped, a.state) + assert.Nil(t, a.anim) + assert.False(t, a.ticker.started()) + + assert.NotPanics(t, func() { a.temporaryStop() }) + assert.NotPanics(t, func() { a.start() }) + assert.NotPanics(t, func() { a.stop() }) +}