Merge pull request #380 from natilou/new-search-dialog

chore: Implement SearchDialog component
This commit is contained in:
Drew Weymouth
2024-05-05 09:49:10 -07:00
committed by GitHub
3 changed files with 310 additions and 240 deletions
+6 -6
View File
@@ -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()
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 {
+24 -234
View File
@@ -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) MinSize() 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
}
+280
View File
@@ -0,0 +1,280 @@
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"
)
// SearchDialog is a base widget to be built upon for creating custom search dialogs
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)
}
}