diff --git a/cmd/fyne_demo/tutorials/container.go b/cmd/fyne_demo/tutorials/container.go
index c7a03d90e5..098a04c58d 100644
--- a/cmd/fyne_demo/tutorials/container.go
+++ b/cmd/fyne_demo/tutorials/container.go
@@ -9,6 +9,7 @@ import (
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/cmd/fyne_demo/data"
"fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -107,6 +108,22 @@ func makeGridLayout(_ fyne.Window) fyne.CanvasObject {
box1, box2, box3, box4)
}
+func makeInnerWindowTab(_ fyne.Window) fyne.CanvasObject {
+ label := widget.NewLabel("Window content for inner demo")
+ win1 := container.NewInnerWindow("Inner Demo", container.NewVBox(
+ label,
+ widget.NewButton("Tap Me", func() {
+ label.SetText("Tapped")
+ })))
+ win1.Icon = theme.FyneLogo()
+
+ win2 := container.NewInnerWindow("Inner2", widget.NewLabel("Win 2"))
+
+ multi := container.NewMultipleWindows()
+ multi.Windows = []*container.InnerWindow{win1, win2}
+ return multi
+}
+
func makeScrollTab(_ fyne.Window) fyne.CanvasObject {
hlist := makeButtonList(20)
vlist := makeButtonList(50)
diff --git a/cmd/fyne_demo/tutorials/data.go b/cmd/fyne_demo/tutorials/data.go
index e146883ee3..d3f1639fe2 100644
--- a/cmd/fyne_demo/tutorials/data.go
+++ b/cmd/fyne_demo/tutorials/data.go
@@ -77,6 +77,11 @@ var (
makeScrollTab,
true,
},
+ "innerwindow": {"InnerWindow",
+ "A window that can be used inside a traditional window to contain a document or content.",
+ makeInnerWindowTab,
+ true,
+ },
"widgets": {"Widgets",
"In this section you can see the features available in the toolkit widget set.\n" +
"Expand the tree on the left to browse the individual tutorial elements.",
@@ -88,6 +93,11 @@ var (
makeAccordionTab,
true,
},
+ "activity": {"Activity",
+ "A spinner indicating activity used in buttons etc.",
+ makeActivityTab,
+ true,
+ },
"button": {"Button",
"Simple widget for user tap handling.",
makeButtonTab,
@@ -181,7 +191,7 @@ var (
TutorialIndex = map[string][]string{
"": {"welcome", "canvas", "animations", "icons", "widgets", "collections", "containers", "dialogs", "windows", "binding", "advanced"},
"collections": {"list", "table", "tree", "gridwrap"},
- "containers": {"apptabs", "border", "box", "center", "doctabs", "grid", "scroll", "split"},
- "widgets": {"accordion", "button", "card", "entry", "form", "input", "progress", "text", "toolbar"},
+ "containers": {"apptabs", "border", "box", "center", "doctabs", "grid", "scroll", "split", "innerwindow"},
+ "widgets": {"accordion", "activity", "button", "card", "entry", "form", "input", "progress", "text", "toolbar"},
}
)
diff --git a/cmd/fyne_demo/tutorials/widget.go b/cmd/fyne_demo/tutorials/widget.go
index 078273494b..3d58bbc9c7 100644
--- a/cmd/fyne_demo/tutorials/widget.go
+++ b/cmd/fyne_demo/tutorials/widget.go
@@ -11,6 +11,7 @@ import (
"fyne.io/fyne/v2/cmd/fyne_demo/data"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/validation"
+ "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
@@ -55,6 +56,55 @@ func makeAccordionTab(_ fyne.Window) fyne.CanvasObject {
return ac
}
+func makeActivityTab(win fyne.Window) fyne.CanvasObject {
+ a1 := widget.NewActivity()
+ a2 := widget.NewActivity()
+
+ var button *widget.Button
+ start := func() {
+ button.Disable()
+ a1.Start()
+ a1.Show()
+ a2.Start()
+ a2.Show()
+
+ defer func() {
+ go func() {
+ time.Sleep(time.Second * 10)
+ a1.Stop()
+ a1.Hide()
+ a2.Stop()
+ a2.Hide()
+
+ button.Enable()
+ }()
+ }()
+ }
+
+ button = widget.NewButton("Animate", start)
+ start()
+
+ return container.NewCenter(container.NewGridWithColumns(1,
+ container.NewCenter(container.NewVBox(
+ container.NewHBox(widget.NewLabel("Working..."), a1),
+ container.NewStack(button, a2))),
+ container.NewCenter(widget.NewButton("Show dialog", func() {
+ prop := canvas.NewRectangle(color.Transparent)
+ prop.SetMinSize(fyne.NewSize(50, 50))
+
+ a3 := widget.NewActivity()
+ d := dialog.NewCustomWithoutButtons("Please wait...", container.NewStack(prop, a3), win)
+ a3.Start()
+ d.Show()
+
+ go func() {
+ time.Sleep(time.Second * 5)
+ a3.Stop()
+ d.Hide()
+ }()
+ }))))
+}
+
func makeButtonTab(_ fyne.Window) fyne.CanvasObject {
disabled := widget.NewButton("Disabled", func() {})
disabled.Disable()
diff --git a/container/innerwindow.go b/container/innerwindow.go
new file mode 100644
index 0000000000..c0a76d6064
--- /dev/null
+++ b/container/innerwindow.go
@@ -0,0 +1,203 @@
+package container
+
+import (
+ "image/color"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ intWidget "fyne.io/fyne/v2/internal/widget"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+var _ fyne.Widget = (*InnerWindow)(nil)
+
+// InnerWindow defines a container that wraps content in a window border - that can then be placed inside
+// a regular container/canvas.
+//
+// Since: 2.5
+type InnerWindow struct {
+ widget.BaseWidget
+
+ CloseIntercept func()
+ OnDragged, OnResized func(*fyne.DragEvent)
+ OnMinimized, OnMaximized, OnTappedBar, OnTappedIcon func()
+ Icon fyne.Resource
+
+ title string
+ content fyne.CanvasObject
+}
+
+// NewInnerWindow creates a new window border around the given `content`, displaying the `title` along the top.
+// This will behave like a normal contain and will probably want to be added to a `MultipleWindows` parent.
+//
+// Since: 2.5
+func NewInnerWindow(title string, content fyne.CanvasObject) *InnerWindow {
+ w := &InnerWindow{title: title, content: content}
+ w.ExtendBaseWidget(w)
+ return w
+}
+
+func (w *InnerWindow) Close() {
+ w.Hide()
+}
+
+func (w *InnerWindow) CreateRenderer() fyne.WidgetRenderer {
+ w.ExtendBaseWidget(w)
+
+ min := &widget.Button{Icon: theme.WindowMinimizeIcon(), Importance: widget.LowImportance, OnTapped: w.OnMinimized}
+ if w.OnMinimized == nil {
+ min.Disable()
+ }
+ max := &widget.Button{Icon: theme.WindowMaximizeIcon(), Importance: widget.LowImportance, OnTapped: w.OnMaximized}
+ if w.OnMaximized == nil {
+ max.Disable()
+ }
+
+ buttons := NewHBox(
+ &widget.Button{Icon: theme.WindowCloseIcon(), Importance: widget.DangerImportance, OnTapped: func() {
+ if f := w.CloseIntercept; f != nil {
+ f()
+ } else {
+ w.Close()
+ }
+ }},
+ min, max)
+
+ var icon fyne.CanvasObject
+ if w.Icon != nil {
+ icon = &widget.Button{Icon: w.Icon, Importance: widget.LowImportance, OnTapped: func() {
+ if f := w.OnTappedIcon; f != nil {
+ f()
+ }
+ }}
+ if w.OnTappedIcon == nil {
+ icon.(*widget.Button).Disable()
+ }
+ }
+ title := newDraggableLabel(w.title, w.OnDragged, w.OnTappedBar)
+ title.Truncation = fyne.TextTruncateEllipsis
+
+ bar := NewBorder(nil, nil, buttons, icon, title)
+ bg := canvas.NewRectangle(theme.OverlayBackgroundColor())
+ contentBG := canvas.NewRectangle(theme.BackgroundColor())
+ corner := newDraggableCorner(w.OnResized)
+
+ objects := []fyne.CanvasObject{bg, contentBG, bar, corner, w.content}
+ return &innerWindowRenderer{ShadowingRenderer: intWidget.NewShadowingRenderer(objects, intWidget.DialogLevel),
+ win: w, bar: bar, bg: bg, corner: corner, contentBG: contentBG}
+}
+
+var _ fyne.WidgetRenderer = (*innerWindowRenderer)(nil)
+
+type innerWindowRenderer struct {
+ *intWidget.ShadowingRenderer
+ min fyne.Size
+
+ win *InnerWindow
+ bar *fyne.Container
+ bg, contentBG *canvas.Rectangle
+ corner fyne.CanvasObject
+}
+
+func (i *innerWindowRenderer) Layout(size fyne.Size) {
+ pad := theme.Padding()
+ pos := fyne.NewSquareOffsetPos(pad / 2)
+ size = size.Subtract(fyne.NewSquareSize(pad))
+ i.LayoutShadow(size, pos)
+
+ i.bg.Move(pos)
+ i.bg.Resize(size)
+
+ barHeight := i.bar.MinSize().Height
+ i.bar.Move(pos.AddXY(pad, 0))
+ i.bar.Resize(fyne.NewSize(size.Width-pad*2, barHeight))
+
+ innerPos := pos.AddXY(pad, barHeight)
+ innerSize := fyne.NewSize(size.Width-pad*2, size.Height-pad-barHeight)
+ i.contentBG.Move(innerPos)
+ i.contentBG.Resize(innerSize)
+ i.win.content.Move(innerPos)
+ i.win.content.Resize(innerSize)
+
+ cornerSize := i.corner.MinSize()
+ i.corner.Move(pos.Add(size).Subtract(cornerSize))
+ i.corner.Resize(cornerSize)
+}
+
+func (i *innerWindowRenderer) MinSize() fyne.Size {
+ pad := theme.Padding()
+ contentMin := i.win.content.MinSize()
+ barMin := i.bar.MinSize()
+
+ // only allow windows to grow, as per normal windows
+ contentMin = contentMin.Max(i.min)
+ i.min = contentMin
+ innerWidth := fyne.Max(barMin.Width, contentMin.Width)
+
+ return fyne.NewSize(innerWidth+pad*2, contentMin.Height+pad+barMin.Height).Add(fyne.NewSquareSize(pad))
+}
+
+func (i *innerWindowRenderer) Refresh() {
+ i.bg.FillColor = theme.OverlayBackgroundColor()
+ i.bg.Refresh()
+ i.contentBG.FillColor = theme.BackgroundColor()
+ i.contentBG.Refresh()
+ i.bar.Refresh()
+
+ i.ShadowingRenderer.RefreshShadow()
+}
+
+type draggableLabel struct {
+ widget.Label
+ drag func(*fyne.DragEvent)
+ tap func()
+}
+
+func newDraggableLabel(title string, fn func(*fyne.DragEvent), tap func()) *draggableLabel {
+ d := &draggableLabel{drag: fn, tap: tap}
+ d.ExtendBaseWidget(d)
+ d.Text = title
+ return d
+}
+
+func (d *draggableLabel) Dragged(ev *fyne.DragEvent) {
+ if f := d.drag; f != nil {
+ d.drag(ev)
+ }
+}
+
+func (d *draggableLabel) DragEnd() {
+}
+
+func (d *draggableLabel) Tapped(ev *fyne.PointEvent) {
+ if f := d.tap; f != nil {
+ d.tap()
+ }
+}
+
+type draggableCorner struct {
+ widget.BaseWidget
+ drag func(*fyne.DragEvent)
+}
+
+func newDraggableCorner(fn func(*fyne.DragEvent)) *draggableCorner {
+ d := &draggableCorner{drag: fn}
+ d.ExtendBaseWidget(d)
+ return d
+}
+
+func (c *draggableCorner) CreateRenderer() fyne.WidgetRenderer {
+ prop := canvas.NewRectangle(color.Transparent)
+ prop.SetMinSize(fyne.NewSquareSize(20))
+ return widget.NewSimpleRenderer(prop)
+}
+
+func (c *draggableCorner) Dragged(ev *fyne.DragEvent) {
+ if f := c.drag; f != nil {
+ c.drag(ev)
+ }
+}
+
+func (c *draggableCorner) DragEnd() {
+}
diff --git a/container/innerwindow_test.go b/container/innerwindow_test.go
new file mode 100644
index 0000000000..7a0e4936ef
--- /dev/null
+++ b/container/innerwindow_test.go
@@ -0,0 +1,50 @@
+package container
+
+import (
+ "testing"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/test"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestInnerWindow_Close(t *testing.T) {
+ w := NewInnerWindow("Thing", widget.NewLabel("Content"))
+
+ outer := test.NewWindow(w)
+ outer.SetPadded(false)
+ outer.Resize(w.MinSize())
+ assert.True(t, w.Visible())
+
+ closePos := fyne.NewPos(10, 10)
+ test.TapCanvas(outer.Canvas(), closePos)
+ assert.False(t, w.Visible())
+
+ w.Show()
+ assert.True(t, w.Visible())
+
+ closing := true
+ w.CloseIntercept = func() {
+ closing = true
+ }
+
+ test.TapCanvas(outer.Canvas(), closePos)
+ assert.True(t, closing)
+ assert.True(t, w.Visible())
+}
+
+func TestInnerWindow_MinSize(t *testing.T) {
+ w := NewInnerWindow("Thing", widget.NewLabel("Content"))
+
+ btnMin := widget.NewButtonWithIcon("", theme.WindowCloseIcon(), func() {}).MinSize()
+ labelMin := widget.NewLabel("Inner").MinSize()
+
+ winMin := w.MinSize()
+ assert.Equal(t, btnMin.Height+labelMin.Height+theme.Padding()*2, winMin.Height)
+ assert.Greater(t, winMin.Width, btnMin.Width*3+theme.Padding()*3)
+
+ w2 := NewInnerWindow("Much longer title that will truncate", widget.NewLabel("Content"))
+ assert.Equal(t, winMin, w2.MinSize())
+}
diff --git a/container/multiplewindows.go b/container/multiplewindows.go
new file mode 100644
index 0000000000..749568e91d
--- /dev/null
+++ b/container/multiplewindows.go
@@ -0,0 +1,105 @@
+package container
+
+import (
+ "fyne.io/fyne/v2"
+ intWidget "fyne.io/fyne/v2/internal/widget"
+ "fyne.io/fyne/v2/widget"
+)
+
+// MultipleWindows is a container that handles multiple `InnerWindow` containers.
+// Each inner window can be dragged, resized and the stacking will change when the title bar is tapped.
+//
+// Since: 2.5
+type MultipleWindows struct {
+ widget.BaseWidget
+
+ Windows []*InnerWindow
+
+ content *fyne.Container
+}
+
+// NewMultipleWindows creates a new `MultipleWindows` container to manage many inner windows.
+// The initial window list is passed optionally to this constructor function.
+// You can add new more windows to this container by calling `Add` or updating the `Windows`
+// field and calling `Refresh`.
+//
+// Since: 2.5
+func NewMultipleWindows(wins ...*InnerWindow) *MultipleWindows {
+ m := &MultipleWindows{Windows: wins}
+ m.ExtendBaseWidget(m)
+ return m
+}
+
+func (m *MultipleWindows) Add(w *InnerWindow) {
+ m.Windows = append(m.Windows, w)
+ m.refreshChildren()
+}
+
+func (m *MultipleWindows) CreateRenderer() fyne.WidgetRenderer {
+ m.content = New(&multiWinLayout{})
+ m.refreshChildren()
+ return widget.NewSimpleRenderer(intWidget.NewScroll(m.content))
+}
+
+func (m *MultipleWindows) Refresh() {
+ m.refreshChildren()
+ // m.BaseWidget.Refresh()
+}
+
+func (m *MultipleWindows) raise(w *InnerWindow) {
+ id := -1
+ for i, ww := range m.Windows {
+ if ww == w {
+ id = i
+ break
+ }
+ }
+ if id == -1 {
+ return
+ }
+
+ windows := append(m.Windows[:id], m.Windows[id+1:]...)
+ m.Windows = append(windows, w)
+ m.refreshChildren()
+}
+
+func (m *MultipleWindows) refreshChildren() {
+ if m.content == nil {
+ return
+ }
+
+ objs := make([]fyne.CanvasObject, len(m.Windows))
+ for i, w := range m.Windows {
+ objs[i] = w
+
+ m.setupChild(w)
+ }
+ m.content.Objects = objs
+ m.content.Refresh()
+}
+
+func (m *MultipleWindows) setupChild(w *InnerWindow) {
+ w.OnDragged = func(ev *fyne.DragEvent) {
+ w.Move(w.Position().Add(ev.Dragged))
+ }
+ w.OnResized = func(ev *fyne.DragEvent) {
+ size := w.Size().Add(ev.Dragged)
+ w.Resize(size.Max(w.MinSize()))
+ }
+ w.OnTappedBar = func() {
+ m.raise(w)
+ }
+}
+
+type multiWinLayout struct {
+}
+
+func (m *multiWinLayout) Layout(objects []fyne.CanvasObject, _ fyne.Size) {
+ for _, w := range objects { // update the windows so they have real size
+ w.Resize(w.MinSize().Max(w.Size()))
+ }
+}
+
+func (m *multiWinLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
+ return fyne.Size{}
+}
diff --git a/container/multiplewindows_test.go b/container/multiplewindows_test.go
new file mode 100644
index 0000000000..ea895bdfdb
--- /dev/null
+++ b/container/multiplewindows_test.go
@@ -0,0 +1,30 @@
+package container
+
+import (
+ "testing"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/test"
+ "fyne.io/fyne/v2/widget"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMultipleWindows_Add(t *testing.T) {
+ m := NewMultipleWindows()
+ assert.Zero(t, len(m.Windows))
+
+ m.Add(NewInnerWindow("1", widget.NewLabel("Inside")))
+ assert.Equal(t, 1, len(m.Windows))
+}
+
+func TestMultipleWindows_Drag(t *testing.T) {
+ w := NewInnerWindow("1", widget.NewLabel("Inside"))
+ m := NewMultipleWindows(w)
+ _ = test.WidgetRenderer(m) // initialise display
+ assert.Equal(t, 1, len(m.Windows))
+
+ assert.True(t, w.Position().IsZero())
+ w.OnDragged(&fyne.DragEvent{Dragged: fyne.Delta{DX: 10, DY: 5}})
+ assert.Equal(t, float32(10), w.Position().X)
+ assert.Equal(t, float32(5), w.Position().Y)
+}
diff --git a/dialog/file.go b/dialog/file.go
index 95a5f252f7..b2ced442b5 100644
--- a/dialog/file.go
+++ b/dialog/file.go
@@ -23,6 +23,8 @@ const (
listView
)
+const viewLayoutKey = "fyne:fileDialogViewLayout"
+
type textWidget interface {
fyne.Widget
SetText(string)
@@ -107,91 +109,18 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
if f.file.confirmText != "" {
label = f.file.confirmText
}
- f.open = widget.NewButton(label, func() {
- if f.file.callback == nil {
- f.win.Hide()
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(false)
- }
- return
- }
-
- if f.file.save {
- callback := f.file.callback.(func(fyne.URIWriteCloser, error))
- name := f.fileName.(*widget.Entry).Text
- location, _ := storage.Child(f.dir, name)
-
- exists, _ := storage.Exists(location)
-
- // check if a directory is selected
- listable, err := storage.CanList(location)
-
- if !exists {
- f.win.Hide()
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(true)
- }
- callback(storage.Writer(location))
- return
- } else if err == nil && listable {
- // a directory has been selected
- ShowInformation("Cannot overwrite",
- "Files cannot replace a directory,\ncheck the file name and try again", f.file.parent)
- return
- }
+ f.open = f.makeOpenButton(label)
- ShowConfirm("Overwrite?", "Are you sure you want to overwrite the file\n"+name+"?",
- func(ok bool) {
- if !ok {
- return
- }
- f.win.Hide()
-
- callback(storage.Writer(location))
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(true)
- }
- }, f.file.parent)
- } else if f.selected != nil {
- callback := f.file.callback.(func(fyne.URIReadCloser, error))
- f.win.Hide()
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(true)
- }
- callback(storage.Reader(f.selected))
- } else if f.file.isDirectory() {
- callback := f.file.callback.(func(fyne.ListableURI, error))
- f.win.Hide()
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(true)
- }
- callback(f.dir, nil)
- }
- })
- f.open.Importance = widget.HighImportance
- f.open.Disable()
if f.file.save {
f.fileName.SetText(f.initialFileName)
}
+
dismissLabel := "Cancel"
if f.file.dismissText != "" {
dismissLabel = f.file.dismissText
}
- f.dismiss = widget.NewButton(dismissLabel, func() {
- f.win.Hide()
- if f.file.onClosedCallback != nil {
- f.file.onClosedCallback(false)
- }
- if f.file.callback != nil {
- if f.file.save {
- f.file.callback.(func(fyne.URIWriteCloser, error))(nil, nil)
- } else if f.file.isDirectory() {
- f.file.callback.(func(fyne.ListableURI, error))(nil, nil)
- } else {
- f.file.callback.(func(fyne.URIReadCloser, error))(nil, nil)
- }
- }
- })
+ f.dismiss = f.makeDismissButton(dismissLabel)
+
buttons := container.NewGridWithRows(1, f.dismiss, f.open)
f.filesScroll = container.NewScroll(nil) // filesScroll's content will be set by setView function.
@@ -206,7 +135,13 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
title = label + " Folder"
}
- f.setView(gridView)
+ view := viewLayout(fyne.CurrentApp().Preferences().Int(viewLayoutKey))
+ if view != listView {
+ view = gridView
+ }
+
+ f.setView(view)
+
f.loadFavorites()
f.favoritesList = widget.NewList(
@@ -230,8 +165,15 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
f.optionsMenu(fyne.CurrentApp().Driver().AbsolutePositionForObject(optionsButton), optionsButton.Size())
})
+ var toggleViewButtonIcon fyne.Resource
+ if f.view == gridView {
+ toggleViewButtonIcon = theme.ListIcon()
+ } else {
+ toggleViewButtonIcon = theme.GridIcon()
+ }
+
var toggleViewButton *widget.Button
- toggleViewButton = widget.NewButtonWithIcon("", theme.ListIcon(), func() {
+ toggleViewButton = widget.NewButtonWithIcon("", toggleViewButtonIcon, func() {
if f.view == gridView {
f.setView(listView)
toggleViewButton.SetIcon(theme.GridIcon())
@@ -291,6 +233,95 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
return container.NewBorder(header, footer, nil, nil, body)
}
+func (f *fileDialog) makeOpenButton(label string) *widget.Button {
+ btn := widget.NewButton(label, func() {
+ if f.file.callback == nil {
+ f.win.Hide()
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(false)
+ }
+ return
+ }
+
+ if f.file.save {
+ callback := f.file.callback.(func(fyne.URIWriteCloser, error))
+ name := f.fileName.(*widget.Entry).Text
+ location, _ := storage.Child(f.dir, name)
+
+ exists, _ := storage.Exists(location)
+
+ // check if a directory is selected
+ listable, err := storage.CanList(location)
+
+ if !exists {
+ f.win.Hide()
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(true)
+ }
+ callback(storage.Writer(location))
+ return
+ } else if err == nil && listable {
+ // a directory has been selected
+ ShowInformation("Cannot overwrite",
+ "Files cannot replace a directory,\ncheck the file name and try again", f.file.parent)
+ return
+ }
+
+ ShowConfirm("Overwrite?", "Are you sure you want to overwrite the file\n"+name+"?",
+ func(ok bool) {
+ if !ok {
+ return
+ }
+ f.win.Hide()
+
+ callback(storage.Writer(location))
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(true)
+ }
+ }, f.file.parent)
+ } else if f.selected != nil {
+ callback := f.file.callback.(func(fyne.URIReadCloser, error))
+ f.win.Hide()
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(true)
+ }
+ callback(storage.Reader(f.selected))
+ } else if f.file.isDirectory() {
+ callback := f.file.callback.(func(fyne.ListableURI, error))
+ f.win.Hide()
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(true)
+ }
+ callback(f.dir, nil)
+ }
+ })
+
+ btn.Importance = widget.HighImportance
+ btn.Disable()
+
+ return btn
+}
+
+func (f *fileDialog) makeDismissButton(label string) *widget.Button {
+ btn := widget.NewButton(label, func() {
+ f.win.Hide()
+ if f.file.onClosedCallback != nil {
+ f.file.onClosedCallback(false)
+ }
+ if f.file.callback != nil {
+ if f.file.save {
+ f.file.callback.(func(fyne.URIWriteCloser, error))(nil, nil)
+ } else if f.file.isDirectory() {
+ f.file.callback.(func(fyne.ListableURI, error))(nil, nil)
+ } else {
+ f.file.callback.(func(fyne.URIReadCloser, error))(nil, nil)
+ }
+ }
+ })
+
+ return btn
+}
+
func (f *fileDialog) optionsMenu(position fyne.Position, buttonSize fyne.Size) {
hiddenFiles := widget.NewCheck("Show Hidden Files", func(changed bool) {
f.showHidden = changed
@@ -479,6 +510,8 @@ func (f *fileDialog) setSelected(file fyne.URI, id int) {
func (f *fileDialog) setView(view viewLayout) {
f.view = view
+ fyne.CurrentApp().Preferences().SetInt(viewLayoutKey, int(view))
+
count := func() int {
return len(f.data)
}
diff --git a/dialog/file_test.go b/dialog/file_test.go
index b1f9fde167..16ea1eb656 100644
--- a/dialog/file_test.go
+++ b/dialog/file_test.go
@@ -505,6 +505,47 @@ func TestView(t *testing.T) {
assert.Equal(t, "Dismiss", dismiss.Text)
}
+func TestViewPreferences(t *testing.T) {
+ win := test.NewWindow(widget.NewLabel("Content"))
+
+ prefs := fyne.CurrentApp().Preferences()
+
+ // set viewLayout to an invalid value to verify that this situation is handled properly
+ prefs.SetInt(viewLayoutKey, -1)
+
+ dlg := NewFileOpen(func(reader fyne.URIReadCloser, err error) {
+ assert.Nil(t, err)
+ assert.Nil(t, reader)
+ }, win)
+
+ dlg.Show()
+
+ popup := win.Canvas().Overlays().Top().(*widget.PopUp)
+ defer win.Canvas().Overlays().Remove(popup)
+ assert.NotNil(t, popup)
+
+ ui := popup.Content.(*fyne.Container)
+ toggleViewButton := ui.Objects[1].(*fyne.Container).Objects[0].(*fyne.Container).Objects[1].(*widget.Button)
+
+ // viewLayout preference should be 'grid'
+ view := viewLayout(prefs.Int(viewLayoutKey))
+ assert.Equal(t, gridView, view)
+
+ // toggle view
+ test.Tap(toggleViewButton)
+
+ // viewLayout preference should be 'list'
+ view = viewLayout(prefs.Int(viewLayoutKey))
+ assert.Equal(t, listView, view)
+
+ // toggle view
+ test.Tap(toggleViewButton)
+
+ // viewLayout preference should be 'grid' again
+ view = viewLayout(prefs.Int(viewLayoutKey))
+ assert.Equal(t, gridView, view)
+}
+
func TestFileFavorites(t *testing.T) {
win := test.NewWindow(widget.NewLabel("Content"))
diff --git a/internal/painter/draw.go b/internal/painter/draw.go
index 127ee57737..b48d615a8d 100644
--- a/internal/painter/draw.go
+++ b/internal/painter/draw.go
@@ -16,14 +16,15 @@ const quarterCircleControl = 1 - 0.55228
// The bounds of the output image will be increased by vectorPad to allow for stroke overflow at the edges.
// The scale function is used to understand how many pixels are required per unit of size.
func DrawCircle(circle *canvas.Circle, vectorPad float32, scale func(float32) float32) *image.RGBA {
- radius := fyne.Min(circle.Size().Width, circle.Size().Height) / 2
+ size := circle.Size()
+ radius := fyne.Min(size.Width, size.Height) / 2
- width := int(scale(circle.Size().Width + vectorPad*2))
- height := int(scale(circle.Size().Height + vectorPad*2))
+ width := int(scale(size.Width + vectorPad*2))
+ height := int(scale(size.Height + vectorPad*2))
stroke := scale(circle.StrokeWidth)
raw := image.NewRGBA(image.Rect(0, 0, width, height))
- scanner := rasterx.NewScannerGV(int(circle.Size().Width), int(circle.Size().Height), raw, raw.Bounds())
+ scanner := rasterx.NewScannerGV(int(size.Width), int(size.Height), raw, raw.Bounds())
if circle.FillColor != nil {
filler := rasterx.NewFiller(width, height, scanner)
diff --git a/internal/painter/gl/draw.go b/internal/painter/gl/draw.go
index 96e1e5bd7e..0609a2d4e3 100644
--- a/internal/painter/gl/draw.go
+++ b/internal/painter/gl/draw.go
@@ -29,6 +29,65 @@ func (p *painter) defineVertexArray(prog Program, name string, size, stride, off
func (p *painter) drawCircle(circle *canvas.Circle, pos fyne.Position, frame fyne.Size) {
p.drawTextureWithDetails(circle, p.newGlCircleTexture, pos, circle.Size(), frame, canvas.ImageFillStretch,
1.0, paint.VectorPad(circle))
+
+ size := circle.Size()
+ radius := size.Width / 2
+ if size.Height < size.Width {
+ radius = size.Height / 2
+ }
+ program := p.roundRectangleProgram
+
+ // Vertex: BEG
+ bounds, points := p.vecSquareCoords(pos, circle, frame)
+ p.ctx.UseProgram(program)
+ vbo := p.createBuffer(points)
+ p.defineVertexArray(program, "vert", 2, 4, 0)
+ p.defineVertexArray(program, "normal", 2, 4, 2)
+
+ p.ctx.BlendFunc(srcAlpha, oneMinusSrcAlpha)
+ p.logError()
+ // Vertex: END
+
+ // Fragment: BEG
+ frameSizeUniform := p.ctx.GetUniformLocation(program, "frame_size")
+ frameWidthScaled, frameHeightScaled := p.scaleFrameSize(frame)
+ p.ctx.Uniform2f(frameSizeUniform, frameWidthScaled, frameHeightScaled)
+
+ rectCoordsUniform := p.ctx.GetUniformLocation(program, "rect_coords")
+ x1Scaled, x2Scaled, y1Scaled, y2Scaled := p.scaleRectCoords(bounds[0], bounds[2], bounds[1], bounds[3])
+ p.ctx.Uniform4f(rectCoordsUniform, x1Scaled, x2Scaled, y1Scaled, y2Scaled)
+
+ strokeWidthScaled := roundToPixel(circle.StrokeWidth*p.pixScale, 1.0)
+ strokeUniform := p.ctx.GetUniformLocation(program, "stroke_width_half")
+ p.ctx.Uniform1f(strokeUniform, strokeWidthScaled*0.5)
+
+ rectSizeUniform := p.ctx.GetUniformLocation(program, "rect_size_half")
+ rectSizeWidthScaled := x2Scaled - x1Scaled - strokeWidthScaled
+ rectSizeHeightScaled := y2Scaled - y1Scaled - strokeWidthScaled
+ p.ctx.Uniform2f(rectSizeUniform, rectSizeWidthScaled*0.5, rectSizeHeightScaled*0.5)
+
+ radiusUniform := p.ctx.GetUniformLocation(program, "radius")
+ radiusScaled := roundToPixel(radius*p.pixScale, 1.0)
+ p.ctx.Uniform1f(radiusUniform, radiusScaled)
+
+ var r, g, b, a float32
+ fillColorUniform := p.ctx.GetUniformLocation(program, "fill_color")
+ r, g, b, a = getFragmentColor(circle.FillColor)
+ p.ctx.Uniform4f(fillColorUniform, r, g, b, a)
+
+ strokeColorUniform := p.ctx.GetUniformLocation(program, "stroke_color")
+ strokeColor := circle.StrokeColor
+ if strokeColor == nil {
+ strokeColor = color.Transparent
+ }
+ r, g, b, a = getFragmentColor(strokeColor)
+ p.ctx.Uniform4f(strokeColorUniform, r, g, b, a)
+ p.logError()
+ // Fragment: END
+
+ p.ctx.DrawArrays(triangleStrip, 0, 4)
+ p.logError()
+ p.freeBuffer(vbo)
}
func (p *painter) drawGradient(o fyne.CanvasObject, texCreator func(fyne.CanvasObject) Texture, pos fyne.Position, frame fyne.Size) {
@@ -338,15 +397,19 @@ func rectInnerCoords(size fyne.Size, pos fyne.Position, fill canvas.ImageFill, a
}
func (p *painter) vecRectCoords(pos fyne.Position, rect *canvas.Rectangle, frame fyne.Size) ([4]float32, []float32) {
+ return p.vecRectCoordsWithPad(pos, rect, frame, 0, 0)
+}
+
+func (p *painter) vecRectCoordsWithPad(pos fyne.Position, rect fyne.CanvasObject, frame fyne.Size, xPad, yPad float32) ([4]float32, []float32) {
size := rect.Size()
pos1 := rect.Position()
- xPosDiff := pos.X - pos1.X
- yPosDiff := pos.Y - pos1.Y
+ xPosDiff := pos.X - pos1.X + xPad
+ yPosDiff := pos.Y - pos1.Y + yPad
pos1.X = roundToPixel(pos1.X+xPosDiff, p.pixScale)
pos1.Y = roundToPixel(pos1.Y+yPosDiff, p.pixScale)
- size.Width = roundToPixel(size.Width, p.pixScale)
- size.Height = roundToPixel(size.Height, p.pixScale)
+ size.Width = roundToPixel(size.Width-2*xPad, p.pixScale)
+ size.Height = roundToPixel(size.Height-2*yPad, p.pixScale)
x1Pos := pos1.X
x1Norm := -1 + x1Pos*2/frame.Width
@@ -367,6 +430,18 @@ func (p *painter) vecRectCoords(pos fyne.Position, rect *canvas.Rectangle, frame
return [4]float32{x1Pos, y1Pos, x2Pos, y2Pos}, coords
}
+func (p *painter) vecSquareCoords(pos fyne.Position, rect fyne.CanvasObject, frame fyne.Size) ([4]float32, []float32) {
+ xPad, yPad := float32(0), float32(0)
+ size := rect.Size()
+ if size.Width > size.Height {
+ xPad = (size.Width - size.Height) / 2
+ } else {
+ yPad = (size.Height - size.Width) / 2
+ }
+
+ return p.vecRectCoordsWithPad(pos, rect, frame, xPad, yPad)
+}
+
func roundToPixel(v float32, pixScale float32) float32 {
if pixScale == 1.0 {
return float32(math.Round(float64(v)))
diff --git a/internal/repository/memory.go b/internal/repository/memory.go
index e7870a07a1..3537f58523 100644
--- a/internal/repository/memory.go
+++ b/internal/repository/memory.go
@@ -1,13 +1,13 @@
package repository
import (
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/storage"
- "fyne.io/fyne/v2/storage/repository"
-
"fmt"
"io"
"strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/storage/repository"
)
// declare conformance to interfaces
@@ -297,7 +297,7 @@ func (m *InMemoryRepository) List(u fyne.URI) ([]fyne.URI, error) {
// does not have one.
pSplit := strings.Split(p, "/")
ncomp := len(pSplit)
- if p[len(p)-1] == '/' {
+ if len(p) > 0 && p[len(p)-1] == '/' {
ncomp--
}
diff --git a/internal/repository/memory_test.go b/internal/repository/memory_test.go
index 17bb5d0eff..b61b59c1da 100644
--- a/internal/repository/memory_test.go
+++ b/internal/repository/memory_test.go
@@ -68,6 +68,10 @@ func TestInMemoryRepositoryParsing(t *testing.T) {
baz, _ := storage.ParseURI("mem:///baz")
assert.Nil(t, err)
assert.NotNil(t, baz)
+
+ empty, _ := storage.ParseURI("mem:")
+ assert.Nil(t, err)
+ assert.NotNil(t, empty)
}
func TestInMemoryRepositoryExists(t *testing.T) {
@@ -328,6 +332,7 @@ func TestInMemoryRepositoryListing(t *testing.T) {
// set up our repository - it's OK if we already registered it
m := NewInMemoryRepository("mem")
repository.Register("mem", m)
+ m.Data[""] = []byte{1, 2, 3}
m.Data["/foo"] = []byte{1, 2, 3}
m.Data["/foo/bar"] = []byte{1, 2, 3}
m.Data["/foo/baz/"] = []byte{1, 2, 3}
@@ -346,6 +351,11 @@ func TestInMemoryRepositoryListing(t *testing.T) {
stringListing = append(stringListing, u.String())
}
assert.ElementsMatch(t, []string{"mem:///foo/bar", "mem:///foo/baz/"}, stringListing)
+
+ empty, _ := storage.ParseURI("mem:") // invalid path
+ canList, err = storage.CanList(empty)
+ assert.NotNil(t, err)
+ assert.False(t, canList)
}
func TestInMemoryRepositoryCreateListable(t *testing.T) {
diff --git a/theme/bundled-icons.go b/theme/bundled-icons.go
index 5e31d3be7e..71b4afd105 100644
--- a/theme/bundled-icons.go
+++ b/theme/bundled-icons.go
@@ -459,3 +459,13 @@ var gridIconRes = &fyne.StaticResource{
StaticName: "grid.svg",
StaticContent: []byte(""),
}
+
+var maximizeIconRes = &fyne.StaticResource{
+ StaticName: "maximize.svg",
+ StaticContent: []byte(""),
+}
+
+var minimizeIconRes = &fyne.StaticResource{
+ StaticName: "minimize.svg",
+ StaticContent: []byte(""),
+}
diff --git a/theme/gen.go b/theme/gen.go
index c512853bbf..45d3fab3d7 100644
--- a/theme/gen.go
+++ b/theme/gen.go
@@ -197,6 +197,9 @@ func main() {
bundleIcon("list", f)
bundleIcon("grid", f)
+ bundleIcon("maximize", f)
+ bundleIcon("minimize", f)
+
err = writeFile("bundled-icons.go", f.Bytes())
if err != nil {
fyne.LogError("unable to write file", err)
diff --git a/theme/icons.go b/theme/icons.go
index 315ee22deb..a7d17ec50a 100644
--- a/theme/icons.go
+++ b/theme/icons.go
@@ -455,6 +455,21 @@ const (
//
// Since: 2.1
IconNameGrid fyne.ThemeIconName = "grid"
+
+ // IconNameWindowClose is the name of theme lookup for window close icon.
+ //
+ // Since: 2.5
+ IconNameWindowClose fyne.ThemeIconName = "windowClose"
+
+ // IconNameWindowMaximize is the name of theme lookup for window maximize icon.
+ //
+ // Since: 2.5
+ IconNameWindowMaximize fyne.ThemeIconName = "windowMaximize"
+
+ // IconNameWindowMinimize is the name of theme lookup for window minimize icon.
+ //
+ // Since: 2.5
+ IconNameWindowMinimize fyne.ThemeIconName = "windowMinimize"
)
var (
@@ -567,6 +582,10 @@ var (
IconNameList: NewThemedResource(listIconRes),
IconNameGrid: NewThemedResource(gridIconRes),
+
+ IconNameWindowClose: NewThemedResource(cancelIconRes),
+ IconNameWindowMaximize: NewThemedResource(maximizeIconRes),
+ IconNameWindowMinimize: NewThemedResource(minimizeIconRes),
}
)
@@ -1221,6 +1240,27 @@ func GridIcon() fyne.Resource {
return safeIconLookup(IconNameGrid)
}
+// WindowCloseIcon returns a resource containing the window close icon for the current theme
+//
+// Since: 2.5
+func WindowCloseIcon() fyne.Resource {
+ return safeIconLookup(IconNameWindowClose)
+}
+
+// WindowMaximizeIcon returns a resource containing the window maximize icon for the current theme
+//
+// Since: 2.5
+func WindowMaximizeIcon() fyne.Resource {
+ return safeIconLookup(IconNameWindowMaximize)
+}
+
+// WindowMinimizeIcon returns a resource containing the window minimize icon for the current theme
+//
+// Since: 2.5
+func WindowMinimizeIcon() fyne.Resource {
+ return safeIconLookup(IconNameWindowMinimize)
+}
+
func safeIconLookup(n fyne.ThemeIconName) fyne.Resource {
icon := current().Icon(n)
if icon == nil {
diff --git a/theme/icons/maximize.svg b/theme/icons/maximize.svg
new file mode 100644
index 0000000000..a47cfe7009
--- /dev/null
+++ b/theme/icons/maximize.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/theme/icons/minimize.svg b/theme/icons/minimize.svg
new file mode 100644
index 0000000000..2b62d30e58
--- /dev/null
+++ b/theme/icons/minimize.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/theme/json.go b/theme/json.go
index 3712574bbb..a0bbf81b67 100644
--- a/theme/json.go
+++ b/theme/json.go
@@ -39,7 +39,7 @@ type hexColor string
func (h hexColor) color() (color.Color, error) {
data := h
- switch len(h) {
+ switch len([]rune(h)) {
case 8, 6:
case 9, 7: // remove # prefix
data = h[1:]
diff --git a/theme/json_test.go b/theme/json_test.go
index 59c676be5d..1b071008bb 100644
--- a/theme/json_test.go
+++ b/theme/json_test.go
@@ -28,6 +28,10 @@ func TestFromJSON(t *testing.T) {
assert.Equal(t, float32(5), th.Size(SizeNameInlineIcon))
assert.Equal(t, "NotoMono-Regular.ttf", th.Font(fyne.TextStyle{Monospace: true}).Name())
assert.Equal(t, "cancel_Paths.svg", th.Icon(IconNameCancel).Name())
+
+ th, _ = FromJSON("{\"Colors\":{\"foreground\":\"\xb1\"}}")
+ c := th.Color(ColorNameForeground, VariantLight)
+ assert.NotNil(t, c)
}
func TestFromTOML_Resource(t *testing.T) {
diff --git a/widget/activity.go b/widget/activity.go
new file mode 100644
index 0000000000..c41d477bde
--- /dev/null
+++ b/widget/activity.go
@@ -0,0 +1,148 @@
+package widget
+
+import (
+ "image/color"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/internal/cache"
+ "fyne.io/fyne/v2/theme"
+)
+
+var _ fyne.Widget = (*Activity)(nil)
+
+// Activity is used to indicate that something is happening that should be waited for,
+// or is in the background (depending on usage).
+//
+// Since: 2.5
+type Activity struct {
+ BaseWidget
+}
+
+// NewActivity returns a widget for indicating activity
+//
+// Since: 2.5
+func NewActivity() *Activity {
+ a := &Activity{}
+ a.ExtendBaseWidget(a)
+ return a
+}
+
+func (a *Activity) MinSize() fyne.Size {
+ a.ExtendBaseWidget(a)
+
+ return fyne.NewSquareSize(theme.IconInlineSize())
+}
+
+// Start the activity indicator animation
+func (a *Activity) Start() {
+ if r, ok := cache.Renderer(a.super()).(*activityRenderer); ok {
+ r.start()
+ }
+}
+
+// Stop the activity indicator animation
+func (a *Activity) Stop() {
+ if r, ok := cache.Renderer(a.super()).(*activityRenderer); ok {
+ r.stop()
+ }
+}
+
+func (a *Activity) CreateRenderer() fyne.WidgetRenderer {
+ dots := make([]fyne.CanvasObject, 3)
+ for i := range dots {
+ dots[i] = canvas.NewCircle(theme.ForegroundColor())
+ }
+ r := &activityRenderer{dots: dots}
+ r.anim = &fyne.Animation{
+ Duration: time.Second * 2,
+ RepeatCount: fyne.AnimationRepeatForever,
+ Tick: r.animate}
+ r.updateColor()
+ return r
+}
+
+var _ fyne.WidgetRenderer = (*activityRenderer)(nil)
+
+type activityRenderer struct {
+ anim *fyne.Animation
+ dots []fyne.CanvasObject
+
+ bound fyne.Size
+ maxCol color.NRGBA
+ maxRad float32
+}
+
+func (a *activityRenderer) Destroy() {
+ a.stop()
+}
+
+func (a *activityRenderer) Layout(size fyne.Size) {
+ a.maxRad = fyne.Min(size.Width, size.Height) / 2
+ a.bound = size
+}
+
+func (a *activityRenderer) MinSize() fyne.Size {
+ return fyne.NewSquareSize(theme.IconInlineSize())
+}
+
+func (a *activityRenderer) Objects() []fyne.CanvasObject {
+ return a.dots
+}
+
+func (a *activityRenderer) Refresh() {
+ a.updateColor()
+}
+
+func (a *activityRenderer) animate(done float32) {
+ off := done * 2
+ if off > 1 {
+ off = 2 - off
+ }
+
+ off1 := (done + 0.25) * 2
+ if done >= 0.75 {
+ off1 = (done - 0.75) * 2
+ }
+ if off1 > 1 {
+ off1 = 2 - off1
+ }
+
+ off2 := (done + 0.75) * 2
+ if done >= 0.25 {
+ off2 = (done - 0.25) * 2
+ }
+ if off2 > 1 {
+ off2 = 2 - off2
+ }
+
+ a.scaleDot(a.dots[0].(*canvas.Circle), off)
+ a.scaleDot(a.dots[1].(*canvas.Circle), off1)
+ a.scaleDot(a.dots[2].(*canvas.Circle), off2)
+}
+
+func (a *activityRenderer) scaleDot(dot *canvas.Circle, off float32) {
+ rad := a.maxRad - a.maxRad*off/1.2
+ mid := fyne.NewPos(a.bound.Width/2, a.bound.Height/2)
+
+ dot.Move(mid.Subtract(fyne.NewSquareOffsetPos(rad)))
+ dot.Resize(fyne.NewSquareSize(rad * 2))
+
+ alpha := uint8(0 + int(float32(a.maxCol.A)*off))
+ dot.FillColor = color.NRGBA{R: a.maxCol.R, G: a.maxCol.G, B: a.maxCol.B, A: alpha}
+ dot.Refresh()
+}
+
+func (a *activityRenderer) start() {
+ a.anim.Start()
+}
+
+func (a *activityRenderer) stop() {
+ a.anim.Stop()
+}
+
+func (a *activityRenderer) updateColor() {
+ rr, gg, bb, aa := theme.ForegroundColor().RGBA()
+ a.maxCol = color.NRGBA{R: uint8(rr >> 8), G: uint8(gg >> 8), B: uint8(bb >> 8), A: uint8(aa >> 8)}
+}
diff --git a/widget/activity_internal_test.go b/widget/activity_internal_test.go
new file mode 100644
index 0000000000..13372186da
--- /dev/null
+++ b/widget/activity_internal_test.go
@@ -0,0 +1,33 @@
+package widget
+
+import (
+ "testing"
+
+ "fyne.io/fyne/v2/test"
+)
+
+func TestActivity_Animation(t *testing.T) {
+ test.NewApp()
+ defer test.NewApp()
+ test.ApplyTheme(t, test.NewTheme())
+
+ a := NewActivity()
+ w := test.NewWindow(a)
+ w.SetPadded(false)
+ defer w.Close()
+ w.Resize(a.MinSize())
+
+ render := test.WidgetRenderer(a).(*activityRenderer)
+ render.anim.Tick(0)
+ test.AssertImageMatches(t, "activity/animate_0.0.png", w.Canvas().Capture())
+
+ render.anim.Tick(0.25)
+ test.AssertImageMatches(t, "activity/animate_0.25.png", w.Canvas().Capture())
+
+ render.anim.Tick(0.5)
+ test.AssertImageMatches(t, "activity/animate_0.5.png", w.Canvas().Capture())
+
+ // check reset to loop
+ render.anim.Tick(1.0)
+ test.AssertImageMatches(t, "activity/animate_0.0.png", w.Canvas().Capture())
+}
diff --git a/widget/activity_test.go b/widget/activity_test.go
new file mode 100644
index 0000000000..a758af384f
--- /dev/null
+++ b/widget/activity_test.go
@@ -0,0 +1,49 @@
+package widget
+
+import (
+ "testing"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestActivity_Start(t *testing.T) {
+ test.NewApp()
+ defer test.NewApp()
+ test.ApplyTheme(t, test.NewTheme())
+
+ a := NewActivity()
+ w := test.NewWindow(a)
+ defer w.Close()
+ w.Resize(fyne.NewSize(50, 50))
+
+ img1 := w.Canvas().Capture()
+ a.Start()
+ time.Sleep(time.Millisecond * 50)
+ img2 := w.Canvas().Capture()
+ a.Stop()
+
+ assert.NotEqual(t, img1, img2)
+}
+
+func TestActivity_Stop(t *testing.T) {
+ test.NewApp()
+ defer test.NewApp()
+ test.ApplyTheme(t, test.NewTheme())
+
+ a := NewActivity()
+ w := test.NewWindow(a)
+ defer w.Close()
+ w.Resize(fyne.NewSize(50, 50))
+
+ a.Start()
+ time.Sleep(time.Millisecond * 50)
+ a.Stop()
+
+ img1 := w.Canvas().Capture()
+ time.Sleep(time.Millisecond * 50)
+ img2 := w.Canvas().Capture()
+ assert.Equal(t, img1, img2)
+}
diff --git a/widget/testdata/activity/animate_0.0.png b/widget/testdata/activity/animate_0.0.png
new file mode 100644
index 0000000000..b98c529989
Binary files /dev/null and b/widget/testdata/activity/animate_0.0.png differ
diff --git a/widget/testdata/activity/animate_0.25.png b/widget/testdata/activity/animate_0.25.png
new file mode 100644
index 0000000000..bdaee492df
Binary files /dev/null and b/widget/testdata/activity/animate_0.25.png differ
diff --git a/widget/testdata/activity/animate_0.5.png b/widget/testdata/activity/animate_0.5.png
new file mode 100644
index 0000000000..15e9e9f7b8
Binary files /dev/null and b/widget/testdata/activity/animate_0.5.png differ