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