From 4eefa4d1fbea20bab469e62e628c4a3b78a62a82 Mon Sep 17 00:00:00 2001 From: natilou Date: Wed, 1 May 2024 11:37:43 -0300 Subject: [PATCH] chore: Implement SearchDialog component Currently, this component is used by QuickSearch dialog. And then, it will be used by the "Add to playlist" dialog. I added some setters and getters to the QuickSearch dialog to abstract the interaction between QuickSearch and SearchDialog from the Controller. After this PR, I will implement the 'Add to playlist' dialog to incorporate the SearchDialog. --- ui/controller/controller.go | 14 +- ui/dialogs/quicksearch.go | 258 ++++----------------------------- ui/dialogs/searchdialog.go | 279 ++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 241 deletions(-) create mode 100644 ui/dialogs/searchdialog.go diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 6e6fe18..60e081a 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -617,12 +617,12 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map func (c *Controller) ShowQuickSearch() { qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager) - pop := widget.NewModalPopUp(qs, c.MainWindow.Canvas()) - qs.OnDismiss = func() { + pop := widget.NewModalPopUp(qs.SearchDialog, c.MainWindow.Canvas()) + qs.SetOnDismiss(func() { pop.Hide() c.doModalClosed() - } - qs.OnNavigateTo = func(contentType mediaprovider.ContentType, id string) { + }) + qs.SetOnNavigateTo(func(contentType mediaprovider.ContentType, id string) { pop.Hide() c.doModalClosed() switch contentType { @@ -637,14 +637,14 @@ func (c *Controller) ShowQuickSearch() { case mediaprovider.ContentTypeGenre: c.NavigateTo(GenreRoute(id)) } - } + }) c.ClosePopUpOnEscape(pop) c.haveModal = true - min := qs.MinSize() + min := qs.GetMinSize() height := fyne.Max(min.Height, fyne.Min(min.Height*1.5, c.MainWindow.Canvas().Size().Height*0.7)) pop.Resize(fyne.NewSize(min.Width, height)) pop.Show() - c.MainWindow.Canvas().Focus(qs.SearchEntry) + c.MainWindow.Canvas().Focus(qs.GetSearchEntry()) } func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error { diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 121190d..a594f69 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -2,133 +2,37 @@ package dialogs import ( "fmt" - "image" "log" - "sync" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/ui/layouts" - myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" - "github.com/dweymouth/supersonic/ui/widgets" ) type QuickSearch struct { - widget.BaseWidget - - OnDismiss func() - OnNavigateTo func(mediaprovider.ContentType, string) - - SearchEntry fyne.Focusable // exported so it can be focused by the Controller - - mp mediaprovider.MediaProvider - imgSource util.ImageFetcher - - resultsMutex sync.RWMutex - searchResults []*mediaprovider.SearchResult - loadingDots *widgets.LoadingDots - list *widget.List - selectedIndex int - - content *fyne.Container + SearchDialog *SearchDialog + mp mediaprovider.MediaProvider } func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { + q := &QuickSearch{ - mp: mp, - imgSource: im, - loadingDots: widgets.NewLoadingDots(), + mp: mp, } - q.ExtendBaseWidget(q) - se := newQuickSearchEntry() - se.OnSearched = q.onSearched - se.OnSubmitted = func(_ string) { - q.onSelected(q.selectedIndex) - } - se.OnTypedDown = q.moveSelectionDown - se.OnTypedUp = q.moveSelectionUp - se.OnTypedEscape = q.onDismiss - q.SearchEntry = se - q.list = widget.NewList( - func() int { - q.resultsMutex.RLock() - defer q.resultsMutex.RUnlock() - return len(q.searchResults) - }, - func() fyne.CanvasObject { return newQuickSearchResult(q) }, - func(lii widget.ListItemID, co fyne.CanvasObject) { - var result *mediaprovider.SearchResult - q.resultsMutex.RLock() - if len(q.searchResults) > lii { - result = q.searchResults[lii] - } - q.resultsMutex.RUnlock() - qs := co.(*quickSearchResult) - qs.index = lii - qs.Update(result) - }, - ) - - 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.NewStack( - container.NewBorder( - container.NewVBox(title, se), - container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)), - nil, nil, q.list), - container.NewCenter(q.loadingDots), + sd := NewSearchDialog( + im, + "Quick Search", + q.onSearched, + q.onUpdateSearchResult, ) + q.SearchDialog = sd return q } -func (q *QuickSearch) onDismiss() { - if q.OnDismiss != nil { - q.OnDismiss() - } -} - -func (q *QuickSearch) onSelected(idx int) { - if q.OnNavigateTo == nil { - return - } - q.resultsMutex.RLock() - if len(q.searchResults) <= idx { - q.resultsMutex.RUnlock() - return - } - id := q.searchResults[idx].ID - typ := q.searchResults[idx].Type - q.resultsMutex.RUnlock() - q.OnNavigateTo(typ, id) -} - -func (q *QuickSearch) moveSelectionDown() { - q.resultsMutex.RLock() - if q.selectedIndex < len(q.searchResults)-1 { - q.selectedIndex++ - } - q.resultsMutex.RUnlock() - q.list.Select(q.selectedIndex) -} - -func (q *QuickSearch) moveSelectionUp() { - q.resultsMutex.RLock() - if q.selectedIndex > 0 { - q.selectedIndex-- - } - q.resultsMutex.RUnlock() - q.list.Select(q.selectedIndex) -} - -func (q *QuickSearch) onSearched(query string) { - q.loadingDots.Start() +func (q *QuickSearch) onSearched(query string) []*mediaprovider.SearchResult { var results []*mediaprovider.SearchResult if query != "" { if res, err := q.mp.SearchAll(query, 20); err != nil { @@ -137,74 +41,10 @@ func (q *QuickSearch) onSearched(query string) { results = res } } - q.loadingDots.Stop() - q.resultsMutex.Lock() - q.searchResults = results - q.resultsMutex.Unlock() - q.list.Refresh() - q.list.ScrollToTop() - q.selectedIndex = 0 - q.list.Select(0) + return results } -func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(q.content) -} - -func (q *QuickSearch) MinSize() fyne.Size { - return fyne.NewSize(400, 350) -} - -type quickSearchResult struct { - widget.BaseWidget - - parent *QuickSearch - - id string - index int - contentType mediaprovider.ContentType - - imageLoader util.ThumbnailLoader - - image *widgets.ImagePlaceholder - title *widget.Label - secondary *widget.RichText - - content *fyne.Container -} - -func newQuickSearchResult(parent *QuickSearch) *quickSearchResult { - qs := &quickSearchResult{ - parent: parent, - image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 50), - title: widget.NewLabel(""), - secondary: widget.NewRichText(), - } - qs.title.Truncation = fyne.TextTruncateEllipsis - qs.secondary.Truncation = fyne.TextTruncateEllipsis - qs.ExtendBaseWidget(qs) - qs.imageLoader = util.NewThumbnailLoader(parent.imgSource, func(im image.Image) { - qs.image.SetImage(im, false) - }) - qs.imageLoader.OnBeforeLoad = func() { - qs.image.SetImage(nil, false) - } - - return qs -} - -func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { - if result == nil { - return - } - if q.contentType == result.Type && q.id == result.ID { - return // nothing to do - } - q.id = result.ID - q.contentType = result.Type - q.image.CenterIcon = placeholderIconForContentType(result.Type) - q.imageLoader.Load(result.CoverID) - q.title.SetText(result.Name) +func (q *QuickSearch) onUpdateSearchResult(sr *searchResult, result *mediaprovider.SearchResult) { maybePluralize := func(s string, size int) string { if size != 1 { @@ -230,14 +70,14 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { secondaryText = "" } } - q.secondary.Segments = []widget.RichTextSegment{ + sr.secondary.Segments = []widget.RichTextSegment{ &widget.TextSegment{ Text: result.Type.String(), Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true}, }, } if secondaryText != "" { - q.secondary.Segments = append(q.secondary.Segments, + sr.secondary.Segments = append(sr.secondary.Segments, &widget.TextSegment{ Text: " ยท ", Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true}, @@ -248,71 +88,21 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { }, ) } - - q.secondary.Refresh() + sr.secondary.Refresh() } -func (q *quickSearchResult) Tapped(_ *fyne.PointEvent) { - q.parent.onSelected(q.index) +func (q *QuickSearch) SetOnDismiss(onDismiss func()) { + q.SearchDialog.OnDismiss = onDismiss } -func (q *quickSearchResult) CreateRenderer() fyne.WidgetRenderer { - if q.content == nil { - q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil, - container.New(&layouts.VboxCustomPadding{ExtraPad: -15}, - q.title, - q.secondary, - )) - } - return widget.NewSimpleRenderer(q.content) +func (q *QuickSearch) SetOnNavigateTo(onNavigateTo func(mediaprovider.ContentType, string)) { + q.SearchDialog.OnNavigateTo = onNavigateTo } -func (q *quickSearchResult) Refresh() { - q.BaseWidget.Refresh() +func (q *QuickSearch) GetMinSize() fyne.Size { + return q.SearchDialog.MinSize() } -type quickSearchEntry struct { - widgets.SearchEntry - - OnTypedUp func() - OnTypedDown func() - OnTypedEscape func() -} - -func newQuickSearchEntry() *quickSearchEntry { - q := &quickSearchEntry{} - q.ExtendBaseWidget(q) - q.SearchEntry.Init() - return q -} - -func (q *quickSearchEntry) TypedKey(e *fyne.KeyEvent) { - switch { - case e.Name == fyne.KeyUp && q.OnTypedUp != nil: - q.OnTypedUp() - case e.Name == fyne.KeyDown && q.OnTypedDown != nil: - q.OnTypedDown() - case e.Name == fyne.KeyEscape && q.OnTypedEscape != nil: - q.OnTypedEscape() - default: - q.SearchEntry.TypedKey(e) - } - -} - -func placeholderIconForContentType(c mediaprovider.ContentType) fyne.Resource { - switch c { - case mediaprovider.ContentTypeAlbum: - return myTheme.AlbumIcon - case mediaprovider.ContentTypeArtist: - return myTheme.ArtistIcon - case mediaprovider.ContentTypeTrack: - return myTheme.TracksIcon - case mediaprovider.ContentTypeGenre: - return myTheme.GenreIcon - case mediaprovider.ContentTypePlaylist: - return myTheme.PlaylistIcon - default: - return theme.WarningIcon() // unreached - } +func (q *QuickSearch) GetSearchEntry() fyne.Focusable { + return q.SearchDialog.SearchEntry } diff --git a/ui/dialogs/searchdialog.go b/ui/dialogs/searchdialog.go new file mode 100644 index 0000000..7379e86 --- /dev/null +++ b/ui/dialogs/searchdialog.go @@ -0,0 +1,279 @@ +package dialogs + +import ( + "image" + "log" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend/mediaprovider" + + "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/util" + "github.com/dweymouth/supersonic/ui/widgets" +) + +type SearchDialog struct { + widget.BaseWidget + + SearchEntry fyne.Focusable // exported so it can be focused by the Controller + + imgSource util.ImageFetcher + + resultsMutex sync.RWMutex + searchResults []*mediaprovider.SearchResult + loadingDots *widgets.LoadingDots + list *widget.List + selectedIndex int + + content *fyne.Container + + OnDismiss func() + OnNavigateTo func(mediaprovider.ContentType, string) + OnSearched func(string) []*mediaprovider.SearchResult + OnUpdateSearchResults func(*searchResult, *mediaprovider.SearchResult) +} + +func NewSearchDialog(im util.ImageFetcher, placeholderTitle string, onSearched func(string) []*mediaprovider.SearchResult, onUpdateSearchResult func(*searchResult, *mediaprovider.SearchResult)) *SearchDialog { + sd := &SearchDialog{ + imgSource: im, + loadingDots: widgets.NewLoadingDots(), + OnSearched: onSearched, + OnUpdateSearchResults: onUpdateSearchResult, + } + sd.ExtendBaseWidget(sd) + + se := newSearchEntry() + se.OnSearched = sd.onSearched + se.OnSubmitted = func(_ string) { + sd.onSelected(sd.selectedIndex) + } + se.OnTypedDown = sd.moveSelectionDown + se.OnTypedUp = sd.moveSelectionUp + se.OnTypedEscape = sd.onDismiss + sd.SearchEntry = se + sd.list = widget.NewList( + func() int { + sd.resultsMutex.RLock() + defer sd.resultsMutex.RUnlock() + return len(sd.searchResults) + }, + func() fyne.CanvasObject { return newSearchResult(sd) }, + func(lii widget.ListItemID, co fyne.CanvasObject) { + var result *mediaprovider.SearchResult + sd.resultsMutex.RLock() + if len(sd.searchResults) > lii { + result = sd.searchResults[lii] + } + sd.resultsMutex.RUnlock() + sr := co.(*searchResult) + sr.index = lii + sd.update(sr, result) + }, + ) + + dismissBtn := widget.NewButton("Close", sd.onDismiss) + title := widget.NewRichText(&widget.TextSegment{Text: placeholderTitle, Style: util.BoldRichTextStyle}) + title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter + sd.content = container.NewStack( + container.NewBorder( + container.NewVBox(title, se), + container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)), + nil, nil, sd.list), + container.NewCenter(sd.loadingDots), + ) + return sd +} + +func (sd *SearchDialog) onDismiss() { + if sd.OnDismiss != nil { + sd.OnDismiss() + } +} + +func (sd *SearchDialog) onSelected(idx int) { + if sd.OnNavigateTo == nil { + return + } + sd.resultsMutex.RLock() + if len(sd.searchResults) <= idx { + sd.resultsMutex.RUnlock() + return + } + id := sd.searchResults[idx].ID + typ := sd.searchResults[idx].Type + sd.resultsMutex.RUnlock() + sd.OnNavigateTo(typ, id) +} + +func (sd *SearchDialog) moveSelectionDown() { + sd.resultsMutex.RLock() + if sd.selectedIndex < len(sd.searchResults)-1 { + sd.selectedIndex++ + } + sd.resultsMutex.RUnlock() + sd.list.Select(sd.selectedIndex) +} + +func (sd *SearchDialog) moveSelectionUp() { + sd.resultsMutex.RLock() + if sd.selectedIndex > 0 { + sd.selectedIndex-- + } + sd.resultsMutex.RUnlock() + sd.list.Select(sd.selectedIndex) +} + +func (sd *SearchDialog) onSearched(query string) { + sd.loadingDots.Start() + var results []*mediaprovider.SearchResult + if query != "" { + res := sd.OnSearched(query) + if len(res) == 0 { + log.Println("No results matched the query.") + } else { + results = res + } + } + sd.loadingDots.Stop() + sd.resultsMutex.Lock() + sd.searchResults = results + sd.resultsMutex.Unlock() + sd.list.Refresh() + sd.list.ScrollToTop() + sd.selectedIndex = 0 + sd.list.Select(0) +} + +func (sd *SearchDialog) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(sd.content) +} + +func (sd *SearchDialog) MinSize() fyne.Size { + return fyne.NewSize(400, 350) +} + +func (sd *SearchDialog) update(sr *searchResult, result *mediaprovider.SearchResult) { + if result == nil { + return + } + if sr.contentType == result.Type && sr.id == result.ID { + return // nothing to do + } + sr.id = result.ID + sr.contentType = result.Type + sr.image.CenterIcon = placeholderIconForContentType(result.Type) + sr.imageLoader.Load(result.CoverID) + sr.title.SetText(result.Name) + + sd.OnUpdateSearchResults(sr, result) +} + +func placeholderIconForContentType(c mediaprovider.ContentType) fyne.Resource { + switch c { + case mediaprovider.ContentTypeAlbum: + return myTheme.AlbumIcon + case mediaprovider.ContentTypeArtist: + return myTheme.ArtistIcon + case mediaprovider.ContentTypeTrack: + return myTheme.TracksIcon + case mediaprovider.ContentTypeGenre: + return myTheme.GenreIcon + case mediaprovider.ContentTypePlaylist: + return myTheme.PlaylistIcon + default: + return theme.WarningIcon() // unreached + } +} + +type searchResult struct { + widget.BaseWidget + + parent *SearchDialog + + id string + index int + contentType mediaprovider.ContentType + + imageLoader util.ThumbnailLoader + + image *widgets.ImagePlaceholder + title *widget.Label + secondary *widget.RichText + + content *fyne.Container +} + +func newSearchResult(parent *SearchDialog) *searchResult { + qs := &searchResult{ + parent: parent, + image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 50), + title: widget.NewLabel(""), + secondary: widget.NewRichText(), + } + qs.title.Truncation = fyne.TextTruncateEllipsis + qs.secondary.Truncation = fyne.TextTruncateEllipsis + qs.ExtendBaseWidget(qs) + qs.imageLoader = util.NewThumbnailLoader(parent.imgSource, func(im image.Image) { + qs.image.SetImage(im, false) + }) + qs.imageLoader.OnBeforeLoad = func() { + qs.image.SetImage(nil, false) + } + + return qs +} + +func (q *searchResult) Tapped(_ *fyne.PointEvent) { + q.parent.onSelected(q.index) +} + +func (q *searchResult) CreateRenderer() fyne.WidgetRenderer { + if q.content == nil { + q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil, + container.New(&layouts.VboxCustomPadding{ExtraPad: -15}, + q.title, + q.secondary, + )) + } + return widget.NewSimpleRenderer(q.content) +} + +func (q *searchResult) Refresh() { + q.BaseWidget.Refresh() +} + +type searchEntry struct { + widgets.SearchEntry + + OnTypedUp func() + OnTypedDown func() + OnTypedEscape func() +} + +func newSearchEntry() *searchEntry { + q := &searchEntry{} + q.ExtendBaseWidget(q) + q.SearchEntry.Init() + return q +} + +func (q *searchEntry) TypedKey(e *fyne.KeyEvent) { + switch { + case e.Name == fyne.KeyUp && q.OnTypedUp != nil: + q.OnTypedUp() + case e.Name == fyne.KeyDown && q.OnTypedDown != nil: + q.OnTypedDown() + case e.Name == fyne.KeyEscape && q.OnTypedEscape != nil: + q.OnTypedEscape() + default: + q.SearchEntry.TypedKey(e) + } + +}