diff --git a/cmd/fyne_settings/settings/appearance.go b/cmd/fyne_settings/settings/appearance.go index 54d4a798fb..ac39b8a739 100644 --- a/cmd/fyne_settings/settings/appearance.go +++ b/cmd/fyne_settings/settings/appearance.go @@ -14,7 +14,7 @@ import ( "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/internal/painter" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/tools/playground" @@ -118,7 +118,7 @@ func (s *Settings) createPreview() image.Image { th = theme.DarkTheme() } - painter.SvgCacheReset() // reset icon cache + cache.ResetSvg() // reset icon cache fyne.CurrentApp().Settings().(overrideTheme).OverrideTheme(th, s.fyneSettings.PrimaryColor) empty := widget.NewLabel("") @@ -136,7 +136,7 @@ func (s *Settings) createPreview() image.Image { time.Sleep(canvas.DurationShort) img := c.Capture() - painter.SvgCacheReset() // ensure we re-create the correct cached assets + cache.ResetSvg() // ensure we re-create the correct cached assets fyne.CurrentApp().Settings().(overrideTheme).OverrideTheme(oldTheme, oldColor) return img } diff --git a/internal/cache/base.go b/internal/cache/base.go new file mode 100644 index 0000000000..ff0982df38 --- /dev/null +++ b/internal/cache/base.go @@ -0,0 +1,190 @@ +package cache + +import ( + "os" + "sync" + "time" + + "fyne.io/fyne/v2" +) + +var ( + cacheDuration = 1 * time.Minute + cleanTaskInterval = cacheDuration / 2 + + expiredObjects = make([]fyne.CanvasObject, 0, 50) + lastClean time.Time + skippedCleanWithCanvasRefresh = false + + // testing purpose only + timeNow func() time.Time = time.Now +) + +func init() { + if t, err := time.ParseDuration(os.Getenv("FYNE_CACHE")); err == nil { + cacheDuration = t + cleanTaskInterval = cacheDuration / 2 + } +} + +// Clean run cache clean task, it should be called on paint events. +func Clean(canvasRefreshed bool) { + now := timeNow() + // do not run clean task too fast + if now.Sub(lastClean) < 10*time.Second { + if canvasRefreshed { + skippedCleanWithCanvasRefresh = true + } + return + } + if skippedCleanWithCanvasRefresh { + skippedCleanWithCanvasRefresh = false + canvasRefreshed = true + } + if !canvasRefreshed && now.Sub(lastClean) < cleanTaskInterval { + return + } + destroyExpiredSvgs(now) + if canvasRefreshed { + // Destroy renderers on canvas refresh to avoid flickering screen. + destroyExpiredRenderers(now) + // canvases cache should be invalidated only on canvas refresh, otherwise there wouldn't + // be a way to recover them later + destroyExpiredCanvases(now) + } + lastClean = timeNow() +} + +// CleanCanvas performs a complete remove of all the objects that belong to the specified +// canvas. Usually used to free all objects from a closing windows. +func CleanCanvas(canvas fyne.Canvas) { + deletingObjs := make([]fyne.CanvasObject, 0, 50) + + canvasesLock.RLock() + for obj, cinfo := range canvases { + if cinfo.canvas == canvas { + deletingObjs = append(deletingObjs, obj) + } + } + canvasesLock.RUnlock() + if len(deletingObjs) == 0 { + return + } + + canvasesLock.Lock() + for _, dobj := range deletingObjs { + delete(canvases, dobj) + } + canvasesLock.Unlock() + + renderersLock.Lock() + for _, dobj := range deletingObjs { + wid, ok := dobj.(fyne.Widget) + if !ok { + continue + } + winfo, ok := renderers[wid] + if !ok { + continue + } + winfo.renderer.Destroy() + delete(renderers, wid) + } + renderersLock.Unlock() +} + +// destroyExpiredCanvases deletes objects from the canvases cache. +func destroyExpiredCanvases(now time.Time) { + expiredObjects = expiredObjects[:0] + canvasesLock.RLock() + for obj, cinfo := range canvases { + if cinfo.isExpired(now) { + expiredObjects = append(expiredObjects, obj) + } + } + canvasesLock.RUnlock() + if len(expiredObjects) > 0 { + canvasesLock.Lock() + for i, exp := range expiredObjects { + delete(canvases, exp) + expiredObjects[i] = nil + } + canvasesLock.Unlock() + } +} + +// destroyExpiredRenderers deletes the renderer from the cache and calls +// renderer.Destroy() +func destroyExpiredRenderers(now time.Time) { + expiredObjects = expiredObjects[:0] + renderersLock.RLock() + for wid, rinfo := range renderers { + if rinfo.isExpired(now) { + rinfo.renderer.Destroy() + expiredObjects = append(expiredObjects, wid) + } + } + renderersLock.RUnlock() + if len(expiredObjects) > 0 { + renderersLock.Lock() + for i, exp := range expiredObjects { + delete(renderers, exp.(fyne.Widget)) + expiredObjects[i] = nil + } + renderersLock.Unlock() + } +} + +// destroyExpiredSvgs destroys expired svgs cache data. +func destroyExpiredSvgs(now time.Time) { + expiredSvgs := make([]string, 0, 20) + svgLock.RLock() + for s, sinfo := range svgs { + if sinfo.isExpired(now) { + expiredSvgs = append(expiredSvgs, s) + } + } + svgLock.RUnlock() + if len(expiredSvgs) > 0 { + svgLock.Lock() + for _, exp := range expiredSvgs { + delete(svgs, exp) + } + svgLock.Unlock() + } +} + +type expiringCache struct { + expireLock sync.RWMutex + expires time.Time +} + +// isExpired check if the cache data is expired. +func (c *expiringCache) isExpired(now time.Time) bool { + c.expireLock.RLock() + defer c.expireLock.RUnlock() + return c.expires.Before(now) +} + +// setAlive updates expiration time. +func (c *expiringCache) setAlive() { + t := timeNow().Add(cacheDuration) + c.expireLock.Lock() + c.expires = t + c.expireLock.Unlock() +} + +type expiringCacheNoLock struct { + expires time.Time +} + +// isExpired check if the cache data is expired. +func (c *expiringCacheNoLock) isExpired(now time.Time) bool { + return c.expires.Before(now) +} + +// setAlive updates expiration time. +func (c *expiringCacheNoLock) setAlive() { + t := timeNow().Add(cacheDuration) + c.expires = t +} diff --git a/internal/cache/base_test.go b/internal/cache/base_test.go new file mode 100644 index 0000000000..e65a178596 --- /dev/null +++ b/internal/cache/base_test.go @@ -0,0 +1,273 @@ +package cache + +import ( + "fmt" + "os" + "testing" + "time" + + "fyne.io/fyne/v2" + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + ret := m.Run() + testClearAll() + os.Exit(ret) +} + +func TestCacheClean(t *testing.T) { + destroyedRenderersCnt := 0 + testClearAll() + tm := &timeMock{} + + for k := 0; k < 2; k++ { + tm.setTime(10, 10+k*10) + for i := 0; i < 20; i++ { + SetSvg(fmt.Sprintf("%d%d", k, i), nil, i, i+1) + Renderer(&dummyWidget{onDestroy: func() { + destroyedRenderersCnt++ + }}) + SetCanvasForObject(&dummyWidget{}, &dummyCanvas{}) + } + } + + t.Run("no_expired_objects", func(t *testing.T) { + lastClean = tm.createTime(10, 20) + Clean(false) + assert.Len(t, svgs, 40) + assert.Len(t, renderers, 40) + assert.Len(t, canvases, 40) + assert.Zero(t, destroyedRenderersCnt) + assert.Equal(t, tm.now, lastClean) + + tm.setTime(10, 30) + Clean(true) + assert.Len(t, svgs, 40) + assert.Len(t, renderers, 40) + assert.Len(t, canvases, 40) + assert.Zero(t, destroyedRenderersCnt) + assert.Equal(t, tm.now, lastClean) + }) + + t.Run("do_not_clean_too_fast", func(t *testing.T) { + lastClean = tm.createTime(10, 30) + // when no canvas refresh and has been transcurred less than + // cleanTaskInterval duration, no clean task should occur. + tm.setTime(10, 42) + Clean(false) + assert.Less(t, lastClean.UnixNano(), tm.now.UnixNano()) + + Clean(true) + assert.Equal(t, tm.now, lastClean) + + // when canvas refresh the clean task is only executed if it has been + // transcurred more than 10 seconds since the lastClean. + tm.setTime(10, 45) + Clean(true) + assert.Less(t, lastClean.UnixNano(), tm.now.UnixNano()) + + tm.setTime(10, 53) + Clean(true) + assert.Equal(t, tm.now, lastClean) + + assert.Len(t, svgs, 40) + assert.Len(t, renderers, 40) + assert.Len(t, canvases, 40) + assert.Zero(t, destroyedRenderersCnt) + }) + + t.Run("clean_no_canvas_refresh", func(t *testing.T) { + lastClean = tm.createTime(10, 11) + tm.setTime(11, 12) + Clean(false) + assert.Len(t, svgs, 20) + assert.Len(t, renderers, 40) + assert.Len(t, canvases, 40) + assert.Zero(t, destroyedRenderersCnt) + + tm.setTime(11, 42) + Clean(false) + assert.Len(t, svgs, 0) + assert.Len(t, renderers, 40) + assert.Len(t, canvases, 40) + assert.Zero(t, destroyedRenderersCnt) + }) + + t.Run("clean_canvas_refresh", func(t *testing.T) { + lastClean = tm.createTime(10, 11) + tm.setTime(11, 11) + Clean(true) + assert.Len(t, svgs, 0) + assert.Len(t, renderers, 20) + assert.Len(t, canvases, 20) + assert.Equal(t, 20, destroyedRenderersCnt) + + tm.setTime(11, 22) + Clean(true) + assert.Len(t, svgs, 0) + assert.Len(t, renderers, 0) + assert.Len(t, canvases, 0) + assert.Equal(t, 40, destroyedRenderersCnt) + }) + + t.Run("skipped_clean_with_canvas_refresh", func(t *testing.T) { + testClearAll() + lastClean = tm.createTime(13, 10) + tm.setTime(13, 10) + assert.False(t, skippedCleanWithCanvasRefresh) + Clean(true) + assert.Equal(t, tm.now, lastClean) + + Renderer(&dummyWidget{}) + + tm.setTime(13, 15) + Clean(true) + assert.True(t, skippedCleanWithCanvasRefresh) + assert.Less(t, lastClean.UnixNano(), tm.now.UnixNano()) + assert.Len(t, renderers, 1) + + tm.setTime(14, 21) + Clean(false) + assert.False(t, skippedCleanWithCanvasRefresh) + assert.Equal(t, tm.now, lastClean) + assert.Len(t, renderers, 0) + }) +} + +func TestCleanCanvas(t *testing.T) { + destroyedRenderersCnt := 0 + testClearAll() + + dcanvas1 := &dummyCanvas{} + dcanvas2 := &dummyCanvas{} + + for i := 0; i < 20; i++ { + dwidget := &dummyWidget{onDestroy: func() { + destroyedRenderersCnt++ + }} + Renderer(dwidget) + SetCanvasForObject(dwidget, dcanvas1) + } + + for i := 0; i < 22; i++ { + dwidget := &dummyWidget{onDestroy: func() { + destroyedRenderersCnt++ + }} + Renderer(dwidget) + SetCanvasForObject(dwidget, dcanvas2) + } + + assert.Len(t, renderers, 42) + assert.Len(t, canvases, 42) + + CleanCanvas(dcanvas1) + assert.Len(t, renderers, 22) + assert.Len(t, canvases, 22) + assert.Equal(t, 20, destroyedRenderersCnt) + for _, cinfo := range canvases { + assert.Equal(t, dcanvas2, cinfo.canvas) + } + + CleanCanvas(dcanvas2) + assert.Len(t, renderers, 0) + assert.Len(t, canvases, 0) + assert.Equal(t, 42, destroyedRenderersCnt) +} + +func Test_expiringCache(t *testing.T) { + tm := &timeMock{} + tm.setTime(10, 10) + + c := &expiringCache{} + assert.True(t, c.isExpired(tm.now)) + + c.setAlive() + + tm.setTime(10, 20) + assert.False(t, c.isExpired(tm.now)) + + tm.setTime(10, 11) + tm.now = tm.now.Add(cacheDuration) + assert.True(t, c.isExpired(tm.now)) +} + +func Test_expiringCacheNoLock(t *testing.T) { + tm := &timeMock{} + tm.setTime(10, 10) + + c := &expiringCacheNoLock{} + assert.True(t, c.isExpired(tm.now)) + + c.setAlive() + + tm.setTime(10, 20) + assert.False(t, c.isExpired(tm.now)) + + tm.setTime(10, 11) + tm.now = tm.now.Add(cacheDuration) + assert.True(t, c.isExpired(tm.now)) +} + +type dummyCanvas struct { + fyne.Canvas +} + +type dummyWidget struct { + fyne.Widget + onDestroy func() +} + +func (w *dummyWidget) CreateRenderer() fyne.WidgetRenderer { + return &dummyWidgetRenderer{widget: w} +} + +type dummyWidgetRenderer struct { + widget *dummyWidget + objects []fyne.CanvasObject +} + +func (r *dummyWidgetRenderer) Destroy() { + if r.widget.onDestroy != nil { + r.widget.onDestroy() + } +} + +func (r *dummyWidgetRenderer) Layout(size fyne.Size) { +} + +func (r *dummyWidgetRenderer) MinSize() fyne.Size { + return fyne.NewSize(0, 0) +} + +func (r *dummyWidgetRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *dummyWidgetRenderer) Refresh() { +} + +type timeMock struct { + now time.Time +} + +func (t *timeMock) createTime(min, sec int) time.Time { + return time.Date(2021, time.June, 15, 2, min, sec, 0, time.UTC) +} + +func (t *timeMock) setTime(min, sec int) { + t.now = time.Date(2021, time.June, 15, 2, min, sec, 0, time.UTC) + timeNow = func() time.Time { + return t.now + } +} + +func testClearAll() { + expiredObjects = make([]fyne.CanvasObject, 0, 50) + skippedCleanWithCanvasRefresh = false + canvases = make(map[fyne.CanvasObject]*canvasInfo, 1024) + svgs = make(map[string]*svgInfo) + textures = make(map[fyne.CanvasObject]*textureInfo, 1024) + renderers = map[fyne.Widget]*rendererInfo{} + timeNow = time.Now +} diff --git a/internal/cache/canvases.go b/internal/cache/canvases.go new file mode 100644 index 0000000000..2cc651b659 --- /dev/null +++ b/internal/cache/canvases.go @@ -0,0 +1,36 @@ +package cache + +import ( + "sync" + + "fyne.io/fyne/v2" +) + +var canvasesLock sync.RWMutex +var canvases = make(map[fyne.CanvasObject]*canvasInfo, 1024) + +// GetCanvasForObject returns the canvas for the specified object. +func GetCanvasForObject(obj fyne.CanvasObject) fyne.Canvas { + canvasesLock.RLock() + cinfo, ok := canvases[obj] + canvasesLock.RUnlock() + if cinfo == nil || !ok { + return nil + } + cinfo.setAlive() + return cinfo.canvas +} + +// SetCanvasForObject sets the canvas for the specified object. +func SetCanvasForObject(obj fyne.CanvasObject, canvas fyne.Canvas) { + cinfo := &canvasInfo{canvas: canvas} + cinfo.setAlive() + canvasesLock.Lock() + canvases[obj] = cinfo + canvasesLock.Unlock() +} + +type canvasInfo struct { + expiringCache + canvas fyne.Canvas +} diff --git a/internal/cache/svg.go b/internal/cache/svg.go new file mode 100644 index 0000000000..d479154a2b --- /dev/null +++ b/internal/cache/svg.go @@ -0,0 +1,47 @@ +package cache + +import ( + "image" + "sync" +) + +var svgLock sync.RWMutex +var svgs = make(map[string]*svgInfo) + +// GetSvg gets svg image from cache if it exists. +func GetSvg(name string, w int, h int) *image.NRGBA { + svgLock.RLock() + sinfo, ok := svgs[name] + svgLock.RUnlock() + if !ok || sinfo == nil || sinfo.w != w || sinfo.h != h { + return nil + } + sinfo.setAlive() + return sinfo.pix +} + +// ResetSvg clears all the svg cache map +func ResetSvg() { + svgLock.Lock() + svgs = make(map[string]*svgInfo) + svgLock.Unlock() +} + +// SetSvg sets a svg into the cache map. +func SetSvg(name string, pix *image.NRGBA, w int, h int) { + sinfo := &svgInfo{ + pix: pix, + w: w, + h: h, + } + sinfo.setAlive() + svgLock.Lock() + svgs[name] = sinfo + svgLock.Unlock() +} + +type svgInfo struct { + expiringCacheNoLock + pix *image.NRGBA + w, h int +} diff --git a/internal/painter/svg_cache_test.go b/internal/cache/svg_test.go similarity index 58% rename from internal/painter/svg_cache_test.go rename to internal/cache/svg_test.go index 72c34763d7..cc5d9da7bb 100644 --- a/internal/painter/svg_cache_test.go +++ b/internal/cache/svg_test.go @@ -1,58 +1,61 @@ -package painter +package cache import ( "image" "testing" - "github.com/stretchr/testify/assert" - "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "github.com/stretchr/testify/assert" ) func TestSvgCacheGet(t *testing.T) { - SvgCacheReset() + ResetSvg() img := addToCache("empty.svg", "", 25, 25) - assert.Equal(t, 1, len(rasters)) + assert.Equal(t, 1, len(svgs)) - newImg := svgCacheGet("empty.svg", 25, 25) + newImg := GetSvg("empty.svg", 25, 25) assert.Equal(t, img, newImg) - miss := svgCacheGet("missing.svg", 25, 25) + miss := GetSvg("missing.svg", 25, 25) assert.Nil(t, miss) - miss = svgCacheGet("empty.svg", 30, 30) + miss = GetSvg("empty.svg", 30, 30) assert.Nil(t, miss) } func TestSvgCacheGet_File(t *testing.T) { - SvgCacheReset() + ResetSvg() img := addFileToCache("testdata/stroke.svg", 25, 25) - assert.Equal(t, 1, len(rasters)) + assert.Equal(t, 1, len(svgs)) - newImg := svgCacheGet("testdata/stroke.svg", 25, 25) + newImg := GetSvg("testdata/stroke.svg", 25, 25) assert.Equal(t, img, newImg) - miss := svgCacheGet("missing.svg", 25, 25) + miss := GetSvg("missing.svg", 25, 25) assert.Nil(t, miss) - miss = svgCacheGet("testdata/stroke.svg", 30, 30) + miss = GetSvg("testdata/stroke.svg", 30, 30) assert.Nil(t, miss) } func TestSvgCacheReset(t *testing.T) { - SvgCacheReset() + ResetSvg() _ = addToCache("empty.svg", "", 25, 25) - assert.Equal(t, 1, len(rasters)) + assert.Equal(t, 1, len(svgs)) - SvgCacheReset() - assert.Equal(t, 0, len(rasters)) + ResetSvg() + assert.Equal(t, 0, len(svgs)) } func addFileToCache(path string, w, h int) image.Image { img := canvas.NewImageFromFile(path) - return PaintImage(img, nil, w, h) + tex := image.NewNRGBA(image.Rect(0, 0, w, h)) + SetSvg(img.File, tex, w, h) + return tex } func addToCache(name, content string, w, h int) image.Image { img := canvas.NewImageFromResource(fyne.NewStaticResource(name, []byte(content))) - return PaintImage(img, nil, w, h) + tex := image.NewNRGBA(image.Rect(0, 0, w, h)) + SetSvg(img.Resource.Name(), tex, w, h) + return tex } diff --git a/internal/cache/texture_common.go b/internal/cache/texture_common.go new file mode 100644 index 0000000000..dfbd583e27 --- /dev/null +++ b/internal/cache/texture_common.go @@ -0,0 +1,64 @@ +package cache + +import ( + "fyne.io/fyne/v2" +) + +// NOTE: Texture cache functions should always be called in +// the same goroutine. + +var textures = make(map[fyne.CanvasObject]*textureInfo, 1024) + +// DeleteTexture deletes the texture from the cache map. +func DeleteTexture(obj fyne.CanvasObject) { + delete(textures, obj) +} + +// GetTexture gets cached texture. +func GetTexture(obj fyne.CanvasObject) (TextureType, bool) { + texInfo, ok := textures[obj] + if texInfo == nil || !ok { + return noTexture, false + } + texInfo.setAlive() + return texInfo.texture, true +} + +// RangeExpiredTexturesFor range over the expired textures for the specified canvas. +// +// Note: If this is used to free textures, then it should be called inside a current +// gl context to ensure textures are deleted from gl. +func RangeExpiredTexturesFor(canvas fyne.Canvas, f func(fyne.CanvasObject)) { + now := timeNow() + for obj, tinfo := range textures { + if tinfo.isExpired(now) && tinfo.canvas == canvas { + f(obj) + } + } +} + +// RangeTexturesFor range over the textures for the specified canvas. +// +// Note: If this is used to free textures, then it should be called inside a current +// gl context to ensure textures are deleted from gl. +func RangeTexturesFor(canvas fyne.Canvas, f func(fyne.CanvasObject)) { + for obj, tinfo := range textures { + if tinfo.canvas == canvas { + f(obj) + } + } +} + +// SetTexture sets cached texture. +func SetTexture(obj fyne.CanvasObject, texture TextureType, canvas fyne.Canvas) { + texInfo := &textureInfo{texture: texture} + texInfo.canvas = canvas + texInfo.setAlive() + textures[obj] = texInfo +} + +// textureCacheBase defines base texture cache object. +type textureCacheBase struct { + expiringCacheNoLock + canvas fyne.Canvas +} diff --git a/internal/cache/texture_desktop.go b/internal/cache/texture_desktop.go new file mode 100644 index 0000000000..cb03a923e0 --- /dev/null +++ b/internal/cache/texture_desktop.go @@ -0,0 +1,13 @@ +// +build !android,!ios,!mobile + +package cache + +// TextureType represents an uploaded GL texture +type TextureType = uint32 + +var noTexture = TextureType(0) + +type textureInfo struct { + textureCacheBase + texture TextureType +} diff --git a/internal/cache/texture_gomobile.go b/internal/cache/texture_gomobile.go new file mode 100644 index 0000000000..33bdcab16a --- /dev/null +++ b/internal/cache/texture_gomobile.go @@ -0,0 +1,15 @@ +// +build android ios mobile + +package cache + +import "github.com/fyne-io/mobile/gl" + +// TextureType represents an uploaded GL texture +type TextureType = gl.Texture + +var noTexture = gl.Texture{0} + +type textureInfo struct { + textureCacheBase + texture TextureType +} diff --git a/internal/cache/widget.go b/internal/cache/widget.go index e705df680e..0815ff6bcf 100644 --- a/internal/cache/widget.go +++ b/internal/cache/widget.go @@ -6,7 +6,8 @@ import ( "fyne.io/fyne/v2" ) -var renderers sync.Map +var renderersLock sync.RWMutex +var renderers = map[fyne.Widget]*rendererInfo{} type isBaseWidget interface { ExtendBaseWidget(fyne.Widget) @@ -24,29 +25,53 @@ func Renderer(wid fyne.Widget) fyne.WidgetRenderer { wid = wd.super() } } - renderer, ok := renderers.Load(wid) + + renderersLock.RLock() + rinfo, ok := renderers[wid] + renderersLock.RUnlock() if !ok { - renderer = wid.CreateRenderer() - renderers.Store(wid, renderer) + rinfo = &rendererInfo{renderer: wid.CreateRenderer()} + renderersLock.Lock() + renderers[wid] = rinfo + renderersLock.Unlock() } - if renderer == nil { + if rinfo == nil { return nil } - return renderer.(fyne.WidgetRenderer) + + rinfo.setAlive() + + return rinfo.renderer } // DestroyRenderer frees a render implementation for a widget. // This is typically for internal use only. func DestroyRenderer(wid fyne.Widget) { - Renderer(wid).Destroy() - - renderers.Delete(wid) + renderersLock.RLock() + rinfo, ok := renderers[wid] + renderersLock.RUnlock() + if !ok { + return + } + if rinfo != nil { + rinfo.renderer.Destroy() + } + renderersLock.Lock() + delete(renderers, wid) + renderersLock.Unlock() } // IsRendered returns true of the widget currently has a renderer. // One will be created the first time a widget is shown but may be removed after it is hidden. func IsRendered(wid fyne.Widget) bool { - _, found := renderers.Load(wid) + renderersLock.RLock() + _, found := renderers[wid] + renderersLock.RUnlock() return found } + +type rendererInfo struct { + expiringCache + renderer fyne.WidgetRenderer +} diff --git a/internal/driver/common/canvas.go b/internal/driver/common/canvas.go index 374d1b227b..9b4a18795f 100644 --- a/internal/driver/common/canvas.go +++ b/internal/driver/common/canvas.go @@ -61,9 +61,7 @@ func (c *Canvas) EnsureMinSize() bool { ensureMinSize := func(node *RenderCacheNode) { obj := node.obj - canvasMutex.Lock() - canvases[obj] = c.impl - canvasMutex.Unlock() + cache.SetCanvasForObject(obj, c.impl) if !obj.Visible() { return @@ -197,6 +195,9 @@ func (c *Canvas) FreeDirtyTextures() bool { } driver.WalkCompleteObjectTree(object, freeWalked, nil) default: + cache.RangeExpiredTexturesFor(c.impl, func(obj fyne.CanvasObject) { + c.painter.Free(obj) + }) return freed } } diff --git a/internal/driver/common/driver.go b/internal/driver/common/driver.go index e0cb565500..bcca9b8cde 100644 --- a/internal/driver/common/driver.go +++ b/internal/driver/common/driver.go @@ -1,19 +1,11 @@ package common import ( - "sync" - "fyne.io/fyne/v2" -) - -var ( - canvasMutex sync.RWMutex - canvases = make(map[fyne.CanvasObject]fyne.Canvas) + "fyne.io/fyne/v2/internal/cache" ) // CanvasForObject returns the canvas for the specified object. func CanvasForObject(obj fyne.CanvasObject) fyne.Canvas { - canvasMutex.RLock() - defer canvasMutex.RUnlock() - return canvases[obj] + return cache.GetCanvasForObject(obj) } diff --git a/internal/driver/glfw/loop.go b/internal/driver/glfw/loop.go index d6fa14cd29..dbbb9185a1 100644 --- a/internal/driver/glfw/loop.go +++ b/internal/driver/glfw/loop.go @@ -9,6 +9,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal" "fyne.io/fyne/v2/internal/app" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/painter" "github.com/go-gl/glfw/v3.3/glfw" @@ -198,7 +199,7 @@ func (d *gLDriver) startDrawThread() { } case set := <-settingsChange: painter.ClearFontCache() - painter.SvgCacheReset() + cache.ResetSvg() app.ApplySettingsWithCallback(set, fyne.CurrentApp(), func(w fyne.Window) { c, ok := w.Canvas().(*glCanvas) if !ok { @@ -208,6 +209,7 @@ func (d *gLDriver) startDrawThread() { go c.reloadScale() }) case <-draw.C: + canvasRefreshed := false for _, win := range d.windowList() { w := win.(*window) w.viewLock.RLock() @@ -218,9 +220,10 @@ func (d *gLDriver) startDrawThread() { if closing || !canvas.IsDirty() || !visible { continue } - + canvasRefreshed = true d.repaintWindow(w) } + cache.Clean(canvasRefreshed) } } }() diff --git a/internal/driver/glfw/window.go b/internal/driver/glfw/window.go index 68fc4639e6..7062a30ba5 100644 --- a/internal/driver/glfw/window.go +++ b/internal/driver/glfw/window.go @@ -430,10 +430,16 @@ func (w *window) Close() { return } - w.viewLock.Lock() - w.closing = true - w.viewLock.Unlock() - w.viewport.SetShouldClose(true) + // set w.closing flag inside draw thread to ensure we can free textures + runOnDraw(w, func() { + w.viewLock.Lock() + w.closing = true + w.viewLock.Unlock() + w.viewport.SetShouldClose(true) + cache.RangeTexturesFor(w.canvas, func(obj fyne.CanvasObject) { + w.canvas.Painter().Free(obj) + }) + }) w.canvas.WalkTrees(nil, func(node *common.RenderCacheNode) { if wid, ok := node.Obj().(fyne.Widget); ok { @@ -499,6 +505,7 @@ func (w *window) closed(viewport *glfw.Window) { // destroy this window and, if it's the last window quit the app func (w *window) destroy(d *gLDriver) { w.DestroyEventQueue() + cache.CleanCanvas(w.canvas) if w.master { d.Quit() diff --git a/internal/driver/gomobile/driver.go b/internal/driver/gomobile/driver.go index f6717e7515..74f6e34386 100644 --- a/internal/driver/gomobile/driver.go +++ b/internal/driver/gomobile/driver.go @@ -18,6 +18,7 @@ import ( "fyne.io/fyne/v2/internal" "fyne.io/fyne/v2/internal/animation" intapp "fyne.io/fyne/v2/internal/app" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/driver" "fyne.io/fyne/v2/internal/driver/common" "fyne.io/fyne/v2/internal/painter" @@ -146,7 +147,7 @@ func (d *mobileDriver) Run() { d.sendPaintEvent() case set := <-settingsChange: painter.ClearFontCache() - painter.SvgCacheReset() + cache.ResetSvg() intapp.ApplySettingsWithCallback(set, fyne.CurrentApp(), func(w fyne.Window) { c, ok := w.Canvas().(*mobileCanvas) if !ok { @@ -249,7 +250,8 @@ func (d *mobileDriver) handlePaint(e paint.Event, w fyne.Window) { c.Painter().Init() // we cannot init until the context is set above } - if c.FreeDirtyTextures() || c.IsDirty() { + canvasNeedRefresh := c.FreeDirtyTextures() || c.IsDirty() + if canvasNeedRefresh { newSize := fyne.NewSize(float32(d.currentSize.WidthPx)/c.scale, float32(d.currentSize.HeightPx)/c.scale) if c.EnsureMinSize() { @@ -261,6 +263,7 @@ func (d *mobileDriver) handlePaint(e paint.Event, w fyne.Window) { d.paintWindow(w, newSize) d.app.Publish() } + cache.Clean(canvasNeedRefresh) } func (d *mobileDriver) onStart() { diff --git a/internal/driver/gomobile/window.go b/internal/driver/gomobile/window.go index e524b5c2cb..0f717139c5 100644 --- a/internal/driver/gomobile/window.go +++ b/internal/driver/gomobile/window.go @@ -157,12 +157,20 @@ func (w *window) Close() { d.windows = append(d.windows[:pos], d.windows[pos+1:]...) } + cache.RangeTexturesFor(w.canvas, func(obj fyne.CanvasObject) { + w.canvas.Painter().Free(obj) + }) + w.canvas.WalkTrees(nil, func(node *common.RenderCacheNode) { if wid, ok := node.Obj().(fyne.Widget); ok { cache.DestroyRenderer(wid) } }) + w.QueueEvent(func() { + cache.CleanCanvas(w.canvas) + }) + // Call this in a go routine, because this function could be called // inside a button which callback would be queued in this event queue // and it will lead to a deadlock if this is performed in the same go diff --git a/internal/painter/gl/draw.go b/internal/painter/gl/draw.go index ec89a1aa5a..6a0dab95ed 100644 --- a/internal/painter/gl/draw.go +++ b/internal/painter/gl/draw.go @@ -11,7 +11,7 @@ import ( func (p *glPainter) drawTextureWithDetails(o fyne.CanvasObject, creator func(canvasObject fyne.CanvasObject) Texture, pos fyne.Position, size, frame fyne.Size, fill canvas.ImageFill, alpha float32, pad float32) { - texture := getTexture(o, creator) + texture := p.getTexture(o, creator) if texture == NoTexture { return } diff --git a/internal/painter/gl/gl_common.go b/internal/painter/gl/gl_common.go index d7f7263b13..63981802f3 100644 --- a/internal/painter/gl/gl_common.go +++ b/internal/painter/gl/gl_common.go @@ -10,11 +10,10 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/internal/painter" ) -var textures = make(map[fyne.CanvasObject]Texture, 1024) - func logGLError(err uint32) { if err == 0 { return @@ -27,14 +26,14 @@ func logGLError(err uint32) { } } -func getTexture(object fyne.CanvasObject, creator func(canvasObject fyne.CanvasObject) Texture) Texture { - texture, ok := textures[object] +func (p *glPainter) getTexture(object fyne.CanvasObject, creator func(canvasObject fyne.CanvasObject) Texture) Texture { + texture, ok := cache.GetTexture(object) if !ok { - texture = creator(object) - textures[object] = texture + texture = cache.TextureType(creator(object)) + cache.SetTexture(object, texture, p.canvas) } - return texture + return Texture(texture) } func (p *glPainter) newGlCircleTexture(obj fyne.CanvasObject) Texture { diff --git a/internal/painter/gl/gl_core.go b/internal/painter/gl/gl_core.go index ff9adb9393..2273090e89 100644 --- a/internal/painter/gl/gl_core.go +++ b/internal/painter/gl/gl_core.go @@ -13,6 +13,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/theme" ) @@ -86,7 +87,7 @@ func (p *glPainter) SetOutputSize(width, height int) { } func (p *glPainter) freeTexture(obj fyne.CanvasObject) { - texture, ok := textures[obj] + texture, ok := cache.GetTexture(obj) if !ok { return } @@ -94,7 +95,7 @@ func (p *glPainter) freeTexture(obj fyne.CanvasObject) { tex := uint32(texture) gl.DeleteTextures(1, &tex) logError() - delete(textures, obj) + cache.DeleteTexture(obj) } func glInit() { diff --git a/internal/painter/gl/gl_es.go b/internal/painter/gl/gl_es.go index d534bbc770..7c24856153 100644 --- a/internal/painter/gl/gl_es.go +++ b/internal/painter/gl/gl_es.go @@ -15,6 +15,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/theme" ) @@ -88,7 +89,7 @@ func (p *glPainter) SetOutputSize(width, height int) { } func (p *glPainter) freeTexture(obj fyne.CanvasObject) { - texture, ok := textures[obj] + texture, ok := cache.GetTexture(obj) if !ok { return } @@ -96,7 +97,7 @@ func (p *glPainter) freeTexture(obj fyne.CanvasObject) { tex := uint32(texture) gl.DeleteTextures(1, &tex) logError() - delete(textures, obj) + cache.DeleteTexture(obj) } func glInit() { diff --git a/internal/painter/gl/gl_gomobile.go b/internal/painter/gl/gl_gomobile.go index 2c96226537..ac3aa98857 100644 --- a/internal/painter/gl/gl_gomobile.go +++ b/internal/painter/gl/gl_gomobile.go @@ -14,6 +14,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/theme" ) @@ -98,14 +99,14 @@ func (p *glPainter) SetOutputSize(width, height int) { } func (p *glPainter) freeTexture(obj fyne.CanvasObject) { - texture, ok := textures[obj] + texture, ok := cache.GetTexture(obj) if !ok { return } p.glctx().DeleteTexture(gl.Texture(texture)) p.logError() - delete(textures, obj) + cache.DeleteTexture(obj) } func (p *glPainter) compileShader(source string, shaderType gl.Enum) (gl.Shader, error) { diff --git a/internal/painter/image.go b/internal/painter/image.go index bfbc62cd6d..347d443d51 100644 --- a/internal/painter/image.go +++ b/internal/painter/image.go @@ -14,12 +14,31 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/internal" + "fyne.io/fyne/v2/internal/cache" "github.com/srwiley/oksvg" "github.com/srwiley/rasterx" "golang.org/x/image/draw" ) +var aspects = make(map[interface{}]float32, 16) + +// GetAspect looks up an aspect ratio of an image +func GetAspect(img *canvas.Image) float32 { + aspect := float32(0.0) + if img.Resource != nil { + aspect = aspects[img.Resource.Name()] + } else if img.File != "" { + aspect = aspects[img.File] + } + + if aspect == 0 { + aspect = aspects[img] + } + + return aspect +} + // PaintImage renders a given fyne Image to a Go standard image func PaintImage(img *canvas.Image, c fyne.Canvas, width, height int) image.Image { if width <= 0 || height <= 0 { @@ -50,7 +69,7 @@ func PaintImage(img *canvas.Image, c fyne.Canvas, width, height int) image.Image } if isSVG { - tex := svgCacheGet(name, width, height) + tex := cache.GetSvg(name, width, height) if tex == nil { // Not in cache, so load the item and add to cache @@ -91,7 +110,7 @@ func PaintImage(img *canvas.Image, c fyne.Canvas, width, height int) image.Image return nil } - svgCachePut(name, tex, width, height) + cache.SetSvg(name, tex, width, height) } return tex diff --git a/internal/painter/svg_cache.go b/internal/painter/svg_cache.go deleted file mode 100644 index 81aca2622c..0000000000 --- a/internal/painter/svg_cache.go +++ /dev/null @@ -1,100 +0,0 @@ -package painter - -import ( - "image" - "os" - "sync" - "time" - - "fyne.io/fyne/v2/canvas" -) - -type rasterInfo struct { - pix *image.NRGBA - w, h int - expires time.Time -} - -var cacheDuration = time.Minute * 5 -var rasters = make(map[string]*rasterInfo) -var aspects = make(map[interface{}]float32, 16) -var rasterMutex sync.RWMutex -var janitorOnce sync.Once - -func init() { - if t, err := time.ParseDuration(os.Getenv("FYNE_CACHE")); err == nil { - cacheDuration = t - } - - svgCacheJanitor() -} - -// GetAspect looks up an aspect ratio of an image -func GetAspect(img *canvas.Image) float32 { - aspect := float32(0.0) - if img.Resource != nil { - aspect = aspects[img.Resource.Name()] - } else if img.File != "" { - aspect = aspects[img.File] - } - - if aspect == 0 { - aspect = aspects[img] - } - - return aspect -} - -func svgCacheJanitor() { - delay := cacheDuration / 2 - if delay < time.Second { - delay = time.Second - } - - go janitorOnce.Do(func() { - for { - time.Sleep(delay) - now := time.Now() - rasterMutex.Lock() - for k, v := range rasters { - if v.expires.Before(now) { - delete(rasters, k) - } - } - rasterMutex.Unlock() - } - }) -} - -func svgCacheGet(name string, w int, h int) *image.NRGBA { - rasterMutex.RLock() - defer rasterMutex.RUnlock() - v, ok := rasters[name] - if !ok || v == nil || v.w != w || v.h != h { - return nil - } - v.expires = time.Now().Add(cacheDuration) - return v.pix -} - -func svgCachePut(name string, pix *image.NRGBA, w int, h int) { - rasterMutex.Lock() - defer rasterMutex.Unlock() - defer func() { - recover() - }() - - rasters[name] = &rasterInfo{ - pix: pix, - w: w, - h: h, - expires: time.Now().Add(cacheDuration), - } -} - -// SvgCacheReset clears the SVG cache. -func SvgCacheReset() { - rasterMutex.Lock() - rasters = make(map[string]*rasterInfo) - rasterMutex.Unlock() -} diff --git a/test/testapp.go b/test/testapp.go index 4aad08081e..8e92a55d2b 100644 --- a/test/testapp.go +++ b/test/testapp.go @@ -8,7 +8,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal" "fyne.io/fyne/v2/internal/app" - "fyne.io/fyne/v2/internal/painter" + "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/theme" ) @@ -100,7 +100,7 @@ func NewApp() fyne.App { prefs := internal.NewInMemoryPreferences() test := &testApp{settings: settings, prefs: prefs, storage: &testStorage{}, driver: NewDriver().(*testDriver), lifecycle: &app.Lifecycle{}} - painter.SvgCacheReset() + cache.ResetSvg() fyne.SetCurrentApp(test) listener := make(chan fyne.Settings) @@ -108,7 +108,7 @@ func NewApp() fyne.App { go func() { for { <-listener - painter.SvgCacheReset() + cache.ResetSvg() app.ApplySettings(test.Settings(), test) test.propertyLock.Lock()