From 268860b3eaa59402c8ff8b911a030fccfe8f5df1 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 17 Feb 2024 11:17:06 -0800 Subject: [PATCH] Fix #327: show loading dots when search in progress but no results yet --- ui/dialogs/quicksearch.go | 19 ++++-- ui/widgets/gridview.go | 13 ++++- ui/widgets/loadingdots.go | 118 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 ui/widgets/loadingdots.go diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 0a33bab..121190d 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -31,6 +31,7 @@ type QuickSearch struct { resultsMutex sync.RWMutex searchResults []*mediaprovider.SearchResult + loadingDots *widgets.LoadingDots list *widget.List selectedIndex int @@ -39,8 +40,9 @@ type QuickSearch struct { func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { q := &QuickSearch{ - mp: mp, - imgSource: im, + mp: mp, + imgSource: im, + loadingDots: widgets.NewLoadingDots(), } q.ExtendBaseWidget(q) @@ -76,10 +78,13 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *Quick dismissBtn := widget.NewButton("Close", q.onDismiss) title := widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: util.BoldRichTextStyle}) title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter - q.content = container.NewBorder( - container.NewVBox(title, se), - container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)), - nil, nil, q.list) + q.content = container.NewStack( + container.NewBorder( + container.NewVBox(title, se), + container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)), + nil, nil, q.list), + container.NewCenter(q.loadingDots), + ) return q } @@ -123,6 +128,7 @@ func (q *QuickSearch) moveSelectionUp() { } func (q *QuickSearch) onSearched(query string) { + q.loadingDots.Start() var results []*mediaprovider.SearchResult if query != "" { if res, err := q.mp.SearchAll(query, 20); err != nil { @@ -131,6 +137,7 @@ func (q *QuickSearch) onSearched(query string) { results = res } } + q.loadingDots.Stop() q.resultsMutex.Lock() q.searchResults = results q.resultsMutex.Unlock() diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index e3c4b7d..41c2451 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -11,6 +11,7 @@ import ( "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -72,6 +73,7 @@ type GridView struct { GridViewState grid *disabledGridWrap + loadingDots *LoadingDots menu *widget.PopUpMenu menuGridViewItemId string itemForIndex map[int]*GridViewItem @@ -100,10 +102,12 @@ type GridViewState struct { var _ fyne.Widget = (*GridView)(nil) func newGridView() *GridView { - return &GridView{ + g := &GridView{ + loadingDots: NewLoadingDots(), itemWidth: NewGridViewItem(nil).MinSize().Width, itemForIndex: make(map[int]*GridViewItem), } + return g } func NewFixedGridView(items []GridViewItemModel, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView { @@ -128,6 +132,7 @@ func NewGridView(iter GridViewIterator, fetch util.ImageFetcher, placeholder fyn } g.ExtendBaseWidget(g) g.createGridWrap() + g.loadingDots.Start() // fetch initial items g.checkFetchMoreItems(36) @@ -170,6 +175,7 @@ func (g *GridView) Reset(iter GridViewIterator) { g.iter = iter g.stateMutex.Unlock() g.checkFetchMoreItems(36) + g.loadingDots.Start() g.Refresh() } @@ -345,6 +351,7 @@ func (g *GridView) checkFetchMoreItems(count int) { g.stateMutex.Lock() g.items = append(g.items, items...) g.stateMutex.Unlock() + g.loadingDots.Stop() if len(items) < batchFetchSize { g.done = true } @@ -409,7 +416,9 @@ func (g *GridView) onPlay(itemID string, shuffle bool) { } func (g *GridView) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(g.grid) + return widget.NewSimpleRenderer(container.NewStack( + g.grid, container.NewCenter(g.loadingDots), + )) } // a disabled widget is not considered focusable by the focus manager diff --git a/ui/widgets/loadingdots.go b/ui/widgets/loadingdots.go new file mode 100644 index 0000000..283edf8 --- /dev/null +++ b/ui/widgets/loadingdots.go @@ -0,0 +1,118 @@ +package widgets + +import ( + "context" + "image/color" + "sync/atomic" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/ui/layouts" +) + +type LoadingDots struct { + widget.BaseWidget + + running atomic.Bool + animCancel context.CancelFunc + + dots [3]minSizeCircle + animPos int + container *fyne.Container +} + +func NewLoadingDots() *LoadingDots { + l := &LoadingDots{} + for i := range l.dots { + l.dots[i] = minSizeCircle{Circle: canvas.Circle{ + FillColor: theme.DisabledColor()}, + } + } + l.ExtendBaseWidget(l) + l.Hide() + return l +} + +func (l *LoadingDots) Start() { + if !l.running.CompareAndSwap(false, true) { + return // already started + } + for i := range l.dots { + l.dots[i].Circle.FillColor = theme.DisabledColor() + } + l.animPos = 0 + l.Show() + var ctx context.Context + ctx, l.animCancel = context.WithCancel(context.Background()) + go l.animate(ctx) +} + +func (l *LoadingDots) Stop() { + if !l.running.Load() { + return + } + l.Hide() + l.animCancel() +} + +func (l *LoadingDots) animate(ctx context.Context) { + foreground := theme.ForegroundColor() + disabled := theme.DisabledColor() + l.doTick(foreground, disabled) + ticker := time.NewTicker(333 * time.Millisecond) + for { + select { + case <-ctx.Done(): + ticker.Stop() + l.running.Store(false) + return + case <-ticker.C: + l.doTick(foreground, disabled) + } + } +} + +func (l *LoadingDots) doTick(foreground, disabled color.Color) { + oldDot := l.animPos - 1 + if oldDot == -1 { + oldDot = len(l.dots) - 1 + } + l.dots[l.animPos].Circle.FillColor = foreground + l.dots[oldDot].Circle.FillColor = disabled + l.Refresh() + l.animPos += 1 + if l.animPos >= len(l.dots) { + l.animPos = 0 + } +} + +func (l *LoadingDots) CreateRenderer() fyne.WidgetRenderer { + if l.container == nil { + layout := &layouts.CenterPadLayout{ + PadLeftRight: 3, + } + l.container = container.NewHBox( + container.New(layout, &l.dots[0]), + container.New(layout, &l.dots[1]), + container.New(layout, &l.dots[2]), + ) + } + return widget.NewSimpleRenderer(l.container) +} + +type minSizeCircle struct { + widget.BaseWidget + Circle canvas.Circle +} + +func (m *minSizeCircle) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(&m.Circle) +} + +func (m *minSizeCircle) MinSize() fyne.Size { + return fyne.NewSquareSize(theme.IconInlineSize() / 2) +}