Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Cursor Animation on widget.Entry #1779

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3c03bc8
create entryCursorAnimation object to handle cursor animation, use th…
fpabl0 Jan 13, 2021
3b315f1
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 13, 2021
359f4dd
delete extra comments, remove unused code
fpabl0 Jan 13, 2021
dc4f394
decrease temporary pause time, invert cursor animation, added test
fpabl0 Jan 15, 2021
4f8559e
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 15, 2021
338d76d
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 17, 2021
9896435
merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 17, 2021
f021577
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 17, 2021
db19a08
keep previous enter cursor animation and replace it when pause is req…
fpabl0 Jan 17, 2021
7403c99
Merge 'develop' into fix/entry-cursor-fade
fpabl0 Jan 24, 2021
365b868
update import paths
fpabl0 Jan 24, 2021
4c56395
rename entryCursorAnimation.TemporaryPause to TemporaryStop, remove s…
fpabl0 Jan 26, 2021
3fd96e1
rename safeCounter.cnt to safeCounter.val, remove useless comments, m…
fpabl0 Jan 26, 2021
8095c78
in entry_cursor_anim.go rename cursorStateTemporarilyStopped to curso…
fpabl0 Jan 27, 2021
df544fa
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Jan 27, 2021
cab80a9
remove entryCursorAnim.safeCounter in favor of time.Ticker to manage …
fpabl0 Jan 27, 2021
5743eb4
remove time.Ticker usage in entry_cursor_anim.realTicker in favor of …
fpabl0 Jan 29, 2021
5da8029
remove cursorTicker interface, rename realTicker to cursorTicker, rem…
fpabl0 Feb 4, 2021
ff0ba74
rename cursorTicker.tim and cursorTicker.rstCh to cursorTicker.timer …
fpabl0 Feb 8, 2021
3c5722d
Merge branch 'develop' into fix/entry-cursor-fade
fpabl0 Feb 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 16 additions & 34 deletions widget/entry.go
Expand Up @@ -4,7 +4,6 @@ import (
"image/color"
"math"
"strings"
"time"
"unicode"

"fyne.io/fyne/v2"
Expand Down Expand Up @@ -57,6 +56,8 @@ type Entry struct {
CursorRow, CursorColumn int
OnCursorChanged func() `json:"-"`

cursorAnim *entryCursorAnimation

focused bool
text *textProvider
placeholder *textProvider
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1215,19 +1221,16 @@ 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()
placeholder := e.entry.placeholderProvider()
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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
197 changes: 197 additions & 0 deletions widget/entry_cursor_anim.go
@@ -0,0 +1,197 @@
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 {
tim *time.Timer
fpabl0 marked this conversation as resolved.
Show resolved Hide resolved
rstCh chan struct{}
duration time.Duration
mockWait func() (reset bool)
}

func (t *cursorTicker) Start() {
fpabl0 marked this conversation as resolved.
Show resolved Hide resolved
if t.Started() {
return
}
t.rstCh = make(chan struct{}, 1)
t.tim = time.NewTimer(t.duration)
}

func (t *cursorTicker) Reset() {
if !t.Started() {
return
}
select {
case t.rstCh <- struct{}{}:
default:
}
}

// Stop must be called in the same go routine where WaitTick is used.
func (t *cursorTicker) Stop() {
if !t.Started() {
return
}
if !t.tim.Stop() {
<-t.tim.C
}
t.tim = nil // TODO is it safe?
t.rstCh = nil
}

func (t *cursorTicker) WaitTick() (reset bool) {
if !t.Started() {
reset = true // TODO what to do here?
fpabl0 marked this conversation as resolved.
Show resolved Hide resolved
return
}
if t.mockWait != nil {
return t.mockWait()
}
select {
case <-t.tim.C:
reset = false
t.tim.Stop()
case <-t.rstCh:
reset = true
if !t.tim.Stop() {
<-t.tim.C
}
}
t.tim.Reset(t.duration)
return
}

func (t *cursorTicker) Started() bool { return t.tim != nil }
fpabl0 marked this conversation as resolved.
Show resolved Hide resolved

// ===============================================================
// 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() {
defer func() {
a.mu.Lock()
a.state = cursorStateStopped
a.ticker.Stop()
a.mu.Unlock()
}()
for {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the counter and the loop and I wondered if an alternative approach might be easier to read (and less background processing)... this is just thinking out loud.

Could you wait until you suspect the cursor would wake up (i.e. simple delay), then at that event see if the time has lapsed, and if not (because a new delay was added) then wait the difference between the first delay and the latter, eventually seeing there is no additional delay and return to the running animation?

This would also mean that the animation wakes up after the specified time, without needing to check every 10ms

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was the first thing I had in mind, but the problem is the reset part, if we do not check every "x" time then we will introduce some delays in the animation, or maybe I am wrong?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I don't follow but my understanding was that it would always pause at least the delay time, which may get longer if the user keeps typing. So what is the reset event that we need to check sooner?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm ok, I will implement the logic with a ticker instead maybe it is more clear with it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have thought it would be. Worth exploring.
If it is cleaner without let me know and I will do a more thorough review of this code

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andydotxyz Ok, I have updated the code using now time.Ticker. I am just worry if calling ticker.Reset() so quickly would lead to a bad performance.

Copy link
Member Author

@fpabl0 fpabl0 Jan 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, now I realized that time.Ticker does have Reset() method just from Go 1.15 😞, should I revert the last changes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andydotxyz I have rewritten the solution with time.Timer instead of time.Ticker to ensure backwards compatibility with Go. Tests are ok now! :)

if reset := a.ticker.WaitTick(); reset {
continue
}
a.mu.RLock()
interrupted := a.state == cursorStateInterrupted
cancel := a.anim == nil
a.mu.RUnlock()
if cancel {
return
}
if !interrupted {
continue
}
a.mu.Lock()
if a.anim != nil {
a.anim.Start()
}
a.state = cursorStateRunning
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
}
}