Merge pull request #272 from dweymouth/feature/quick-search
Add "quick search" dialog to search entire library from any page
This commit is contained in:
@@ -37,6 +37,8 @@ type Favorites struct {
|
||||
type MediaProvider interface {
|
||||
SetPrefetchCoverCallback(cb func(coverArtID string))
|
||||
|
||||
GetTrack(trackID string) (*Track, error)
|
||||
|
||||
GetAlbum(albumID string) (*AlbumWithTracks, error)
|
||||
|
||||
GetAlbumInfo(albumID string) (*AlbumInfo, error)
|
||||
@@ -57,6 +59,8 @@ type MediaProvider interface {
|
||||
|
||||
SearchAlbums(searchQuery string, filter AlbumFilter) AlbumIterator
|
||||
|
||||
SearchAll(searchQuery string, maxResults int) ([]*SearchResult, error)
|
||||
|
||||
GetRandomTracks(genre string, count int) ([]*Track, error)
|
||||
|
||||
GetSimilarTracks(artistID string, count int) ([]*Track, error)
|
||||
|
||||
@@ -87,3 +87,45 @@ type PlaylistWithTracks struct {
|
||||
Playlist
|
||||
Tracks []*Track
|
||||
}
|
||||
|
||||
type ContentType int
|
||||
|
||||
const (
|
||||
ContentTypeAlbum ContentType = iota
|
||||
ContentTypeArtist
|
||||
ContentTypePlaylist
|
||||
ContentTypeTrack
|
||||
ContentTypeGenre
|
||||
)
|
||||
|
||||
func (c ContentType) String() string {
|
||||
switch c {
|
||||
case ContentTypeAlbum:
|
||||
return "Album"
|
||||
case ContentTypeArtist:
|
||||
return "Artist"
|
||||
case ContentTypeTrack:
|
||||
return "Track"
|
||||
case ContentTypePlaylist:
|
||||
return "Playlist"
|
||||
case ContentTypeGenre:
|
||||
return "Genre"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Name string
|
||||
ID string
|
||||
CoverID string
|
||||
Type ContentType
|
||||
|
||||
// for Album / Playlist: track count
|
||||
// Artist / Genre: album count
|
||||
// Track: length (seconds)
|
||||
Size int
|
||||
|
||||
// Unset for ContentTypes Artist, Playlist, and Genre
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
||||
var wg sync.WaitGroup
|
||||
var err error // only set by Search3
|
||||
var result *subsonic.SearchResult3
|
||||
var playlists []*subsonic.Playlist
|
||||
var genres []*subsonic.Genre
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
count := strconv.Itoa(maxResults / 3)
|
||||
res, e := s.client.Search3(searchQuery, map[string]string{
|
||||
"artistCount": count,
|
||||
"albumCount": count,
|
||||
"songCount": count,
|
||||
})
|
||||
if e != nil {
|
||||
err = e
|
||||
} else {
|
||||
result = res
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
querySanitized := strings.ToLower(sanitize.Accents(searchQuery))
|
||||
queryLowerWords := strings.Fields(querySanitized)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
p, e := s.client.GetPlaylists(nil)
|
||||
if e == nil {
|
||||
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
|
||||
return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||
})
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
g, e := s.client.GetGenres()
|
||||
if e == nil {
|
||||
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
|
||||
return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||
})
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := mergeResults(result, playlists, genres)
|
||||
rankResults(results, querySanitized, queryLowerWords)
|
||||
if len(results) > maxResults {
|
||||
results = results[:maxResults]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// name and terms should be pre-converted to the same case
|
||||
func allTermsMatch(name string, terms []string) bool {
|
||||
for _, t := range terms {
|
||||
if !strings.Contains(name, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeResults(
|
||||
searchResult *subsonic.SearchResult3,
|
||||
matchingPlaylists []*subsonic.Playlist,
|
||||
matchingGenres []*subsonic.Genre,
|
||||
) []*mediaprovider.SearchResult {
|
||||
var results []*mediaprovider.SearchResult
|
||||
|
||||
for _, al := range searchResult.Album {
|
||||
results = append(results, &mediaprovider.SearchResult{
|
||||
Type: mediaprovider.ContentTypeAlbum,
|
||||
ID: al.ID,
|
||||
CoverID: al.CoverArt,
|
||||
Name: al.Name,
|
||||
ArtistName: getNameString(al.Artist, al.Artists),
|
||||
Size: al.SongCount,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ar := range searchResult.Artist {
|
||||
results = append(results, &mediaprovider.SearchResult{
|
||||
Type: mediaprovider.ContentTypeArtist,
|
||||
ID: ar.ID,
|
||||
CoverID: ar.CoverArt,
|
||||
Name: ar.Name,
|
||||
Size: ar.AlbumCount,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tr := range searchResult.Song {
|
||||
results = append(results, &mediaprovider.SearchResult{
|
||||
Type: mediaprovider.ContentTypeTrack,
|
||||
ID: tr.ID,
|
||||
CoverID: tr.CoverArt,
|
||||
Name: tr.Title,
|
||||
ArtistName: getNameString(tr.Artist, tr.Artists),
|
||||
Size: tr.Duration,
|
||||
})
|
||||
}
|
||||
|
||||
for _, pl := range matchingPlaylists {
|
||||
results = append(results, &mediaprovider.SearchResult{
|
||||
Type: mediaprovider.ContentTypePlaylist,
|
||||
ID: pl.ID,
|
||||
CoverID: pl.CoverArt,
|
||||
Name: pl.Name,
|
||||
Size: pl.SongCount,
|
||||
})
|
||||
}
|
||||
|
||||
for _, g := range matchingGenres {
|
||||
results = append(results, &mediaprovider.SearchResult{
|
||||
Type: mediaprovider.ContentTypeGenre,
|
||||
ID: g.Name,
|
||||
Name: g.Name,
|
||||
Size: g.AlbumCount,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func rankResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) {
|
||||
if len(queryTerms) == 0 || len(results) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
sanitizeMemo := make(map[string]string, len(results))
|
||||
sanitized := func(s string) string {
|
||||
if x, ok := sanitizeMemo[s]; ok {
|
||||
return x
|
||||
}
|
||||
x := strings.ToLower(sanitize.Accents(s))
|
||||
sanitizeMemo[s] = x
|
||||
return x
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
a, b := results[i], results[j]
|
||||
aName := sanitized(a.Name)
|
||||
bName := sanitized(b.Name)
|
||||
|
||||
// Compare by entire query
|
||||
matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery)
|
||||
if matchesA && !matchesB {
|
||||
return true // item A has a direct match with the full query and B does not
|
||||
} else if matchesB && !matchesA {
|
||||
return false // item B matches but not A
|
||||
}
|
||||
|
||||
// Compare by search query terms
|
||||
for _, term := range queryTerms {
|
||||
firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term)
|
||||
if firstTermIdxA >= 0 && firstTermIdxB < 0 {
|
||||
return true // item A has a direct match with the query term and B does not
|
||||
} else if firstTermIdxB >= 0 && firstTermIdxA < 0 {
|
||||
return false // item B matches but not A
|
||||
}
|
||||
|
||||
if firstTermIdxA < firstTermIdxB {
|
||||
return true // item A matches the query term starting at an earlier position
|
||||
} else if firstTermIdxB < firstTermIdxA {
|
||||
return false // item B matches first
|
||||
}
|
||||
}
|
||||
// Defer to item type for priority order
|
||||
return a.Type < b.Type
|
||||
})
|
||||
}
|
||||
|
||||
// select Subsonic single-valued name or join OpenSubsonic multi-valued names
|
||||
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
||||
if len(idNames) == 0 {
|
||||
return singleName
|
||||
}
|
||||
names := sharedutil.MapSlice(idNames, func(a subsonic.IDName) string {
|
||||
return a.Name
|
||||
})
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
@@ -14,9 +14,17 @@ import (
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
const cacheValidDurationSeconds = 60
|
||||
|
||||
type subsonicMediaProvider struct {
|
||||
client *subsonic.Client
|
||||
prefetchCoverCB func(coverArtID string)
|
||||
|
||||
genresCached []*mediaprovider.Genre
|
||||
genresCachedAt int64 // unix
|
||||
|
||||
playlistsCached []*mediaprovider.Playlist
|
||||
playlistsCachedAt int64 // unix
|
||||
}
|
||||
|
||||
func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider {
|
||||
@@ -47,6 +55,14 @@ func (s *subsonicMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []st
|
||||
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove)
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetTrack(trackID string) (*mediaprovider.Track, error) {
|
||||
tr, err := s.client.GetSong(trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTrack(tr), nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetAlbum(albumID string) (*mediaprovider.AlbumWithTracks, error) {
|
||||
al, err := s.client.GetAlbum(albumID)
|
||||
if err != nil {
|
||||
@@ -139,17 +155,23 @@ func (s *subsonicMediaProvider) GetFavorites() (mediaprovider.Favorites, error)
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetGenres() ([]*mediaprovider.Genre, error) {
|
||||
if s.genresCached != nil && time.Now().Unix()-s.genresCachedAt < cacheValidDurationSeconds {
|
||||
return s.genresCached, nil
|
||||
}
|
||||
|
||||
g, err := s.client.GetGenres()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre {
|
||||
s.genresCached = sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre {
|
||||
return &mediaprovider.Genre{
|
||||
Name: g.Name,
|
||||
AlbumCount: g.AlbumCount,
|
||||
TrackCount: g.SongCount,
|
||||
}
|
||||
}), nil
|
||||
})
|
||||
s.genresCachedAt = time.Now().Unix()
|
||||
return s.genresCached, nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) {
|
||||
@@ -165,11 +187,17 @@ func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.P
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) {
|
||||
if s.playlistsCached != nil && time.Now().Unix()-s.playlistsCachedAt < cacheValidDurationSeconds {
|
||||
return s.playlistsCached, nil
|
||||
}
|
||||
|
||||
pl, err := s.client.GetPlaylists(map[string]string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sharedutil.MapSlice(pl, toPlaylist), nil
|
||||
s.playlistsCached = sharedutil.MapSlice(pl, toPlaylist)
|
||||
s.playlistsCachedAt = time.Now().Unix()
|
||||
return s.playlistsCached, nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetRandomTracks(genreName string, count int) ([]*mediaprovider.Track, error) {
|
||||
|
||||
@@ -214,6 +214,15 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl
|
||||
return p.player.PlayTrackAt(firstTrack)
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayTrack(trackID string) error {
|
||||
tr, err := p.sm.Server.GetTrack(trackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.LoadTracks([]*mediaprovider.Track{tr}, false, false)
|
||||
return p.PlayFromBeginning()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayFromBeginning() error {
|
||||
return p.player.PlayFromBeginning()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.19
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.4.1
|
||||
github.com/20after4/configdir v0.1.1
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
||||
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
|
||||
@@ -67,6 +67,8 @@ github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7h
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231104060932-f124004dd651 h1:szaOWq1a8gthqA55qq1egRj660DQM3Pp0nkrmjUwR48=
|
||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231104060932-f124004dd651/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
|
||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
||||
|
||||
@@ -76,6 +76,7 @@ func newArtistsPage(
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.PlaceHolder = "Search page"
|
||||
a.searcher.OnSearched = func(query string) { a.onSearched(query, false /*firstLoad*/) }
|
||||
a.searcher.Entry.Text = searchText
|
||||
if g := pool.Obtain(util.WidgetTypeGridView); g != nil {
|
||||
|
||||
@@ -64,7 +64,7 @@ type BrowsingPane struct {
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewBrowsingPane(app *backend.App) *BrowsingPane {
|
||||
func NewBrowsingPane(app *backend.App, controller *controller.Controller) *BrowsingPane {
|
||||
b := &BrowsingPane{app: app}
|
||||
b.ExtendBaseWidget(b)
|
||||
b.home = widget.NewButtonWithIcon("", theme.HomeIcon(), b.GoHome)
|
||||
@@ -81,13 +81,14 @@ func NewBrowsingPane(app *backend.App) *BrowsingPane {
|
||||
p.ShowAtPosition(fyne.NewPos(b.Size().Width-p.MinSize().Width+4,
|
||||
b.navBtnsContainer.MinSize().Height+theme.Padding()))
|
||||
})
|
||||
quickSearchBtn := widget.NewButtonWithIcon("", theme.SearchIcon(), controller.ShowQuickSearch)
|
||||
b.settingsMenu = fyne.NewMenu("")
|
||||
b.navBtnsContainer = container.NewHBox()
|
||||
b.container = container.NewBorder(container.New(
|
||||
&layouts.MaxPadLayout{PadLeft: -5, PadRight: -5},
|
||||
container.New(layouts.NewLeftMiddleRightLayout(0),
|
||||
container.NewHBox(b.home, b.back, b.forward, b.reload), b.navBtnsContainer,
|
||||
container.NewHBox(layout.NewSpacer(), b.settingsBtn))),
|
||||
container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))),
|
||||
nil, nil, nil, b.pageContainer)
|
||||
b.updateHistoryButtons()
|
||||
return b
|
||||
|
||||
@@ -92,6 +92,7 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int) {
|
||||
widget.NewButtonWithIcon("", myTheme.ArtistIcon, a.onShowFavoriteArtists),
|
||||
widget.NewButtonWithIcon("", myTheme.TracksIcon, a.onShowFavoriteSongs))
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.PlaceHolder = "Search page"
|
||||
a.searcher.OnSearched = a.OnSearched
|
||||
a.searcher.Entry.Text = a.searchText
|
||||
a.filterBtn = widgets.NewAlbumFilterButton(&a.filter, a.mp.GetGenres)
|
||||
|
||||
@@ -49,6 +49,7 @@ func newGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider,
|
||||
a.list = NewGenreList(sorting)
|
||||
a.list.OnNavTo = func(id string) { a.contr.NavigateTo(controller.GenreRoute(id)) }
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.PlaceHolder = "Search page"
|
||||
a.searcher.OnSearched = a.onSearched
|
||||
a.searcher.Entry.Text = searchText
|
||||
a.buildContainer()
|
||||
|
||||
@@ -140,6 +140,7 @@ func (g *GridViewPage) createTitleAndSort() {
|
||||
|
||||
func (g *GridViewPage) createSearchAndFilter() {
|
||||
g.searcher = widgets.NewSearchEntry()
|
||||
g.searcher.PlaceHolder = "Search page"
|
||||
g.searcher.Text = g.searchText
|
||||
g.searcher.OnSearched = g.OnSearched
|
||||
if g.filter != nil {
|
||||
|
||||
@@ -63,6 +63,7 @@ func newPlaylistsPage(contr *controller.Controller, pool *util.WidgetPool, cfg *
|
||||
a.ExtendBaseWidget(a)
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.PlaceHolder = "Search page"
|
||||
a.searcher.OnSearched = a.onSearched
|
||||
a.searcher.Entry.Text = searchText
|
||||
a.viewToggle = widgets.NewToggleButtonGroup(0,
|
||||
|
||||
@@ -68,6 +68,7 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig,
|
||||
t.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName
|
||||
t.playRandom = widget.NewButtonWithIcon("Play random", theme.ShuffleIcon, t.playRandomSongs)
|
||||
t.searcher = widgets.NewSearchEntry()
|
||||
t.searcher.PlaceHolder = "Search page"
|
||||
t.searcher.OnSearched = t.OnSearched
|
||||
t.createContainer()
|
||||
t.Reload()
|
||||
|
||||
@@ -71,6 +71,10 @@ func (m *Controller) QueueShowModalFunc(f func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Controller) HaveModal() bool {
|
||||
return m.haveModal
|
||||
}
|
||||
|
||||
func (m *Controller) ShowPopUpImage(img image.Image) {
|
||||
im := canvas.NewImageFromImage(img)
|
||||
im.FillMode = canvas.ImageFillContain
|
||||
@@ -483,6 +487,35 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
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.Hide()
|
||||
c.doModalClosed()
|
||||
}
|
||||
qs.OnNavigateTo = func(contentType mediaprovider.ContentType, id string) {
|
||||
pop.Hide()
|
||||
c.doModalClosed()
|
||||
switch contentType {
|
||||
case mediaprovider.ContentTypeAlbum:
|
||||
c.NavigateTo(AlbumRoute(id))
|
||||
case mediaprovider.ContentTypeArtist:
|
||||
c.NavigateTo(ArtistRoute(id))
|
||||
case mediaprovider.ContentTypeTrack:
|
||||
go c.App.PlaybackManager.PlayTrack(id)
|
||||
case mediaprovider.ContentTypePlaylist:
|
||||
c.NavigateTo(PlaylistRoute(id))
|
||||
case mediaprovider.ContentTypeGenre:
|
||||
c.NavigateTo(GenreRoute(id))
|
||||
}
|
||||
}
|
||||
c.ClosePopUpOnEscape(pop)
|
||||
c.haveModal = true
|
||||
pop.Show()
|
||||
c.MainWindow.Canvas().Focus(qs.SearchEntry)
|
||||
}
|
||||
|
||||
func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error {
|
||||
if err := c.App.ServerManager.SetServerPassword(server, password); err != nil {
|
||||
log.Printf("error setting keyring credentials: %v", err)
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
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
|
||||
list *widget.List
|
||||
selectedIndex int
|
||||
|
||||
content *fyne.Container
|
||||
}
|
||||
|
||||
func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch {
|
||||
q := &QuickSearch{
|
||||
mp: mp,
|
||||
imgSource: im,
|
||||
}
|
||||
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: boldStyle})
|
||||
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)
|
||||
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) {
|
||||
var results []*mediaprovider.SearchResult
|
||||
if query != "" {
|
||||
if res, err := q.mp.SearchAll(query, 20); err != nil {
|
||||
log.Printf("Error searching: %s", err.Error())
|
||||
} else {
|
||||
results = res
|
||||
}
|
||||
}
|
||||
q.resultsMutex.Lock()
|
||||
q.searchResults = results
|
||||
q.resultsMutex.Unlock()
|
||||
q.list.Refresh()
|
||||
q.list.ScrollToTop()
|
||||
q.selectedIndex = 0
|
||||
q.list.Select(0)
|
||||
}
|
||||
|
||||
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.Wrapping = fyne.TextTruncate
|
||||
qs.secondary.Wrapping = fyne.TextTruncate
|
||||
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)
|
||||
|
||||
maybePluralize := func(s string, size int) string {
|
||||
if size != 1 {
|
||||
return s + "s"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var secondaryText string
|
||||
switch result.Type {
|
||||
case mediaprovider.ContentTypeAlbum:
|
||||
secondaryText = result.ArtistName
|
||||
case mediaprovider.ContentTypeArtist:
|
||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
||||
case mediaprovider.ContentTypeTrack:
|
||||
secondaryText = result.ArtistName
|
||||
case mediaprovider.ContentTypePlaylist:
|
||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
||||
case mediaprovider.ContentTypeGenre:
|
||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
||||
}
|
||||
q.secondary.Segments = []widget.RichTextSegment{
|
||||
&widget.TextSegment{
|
||||
Text: result.Type.String(),
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
||||
},
|
||||
&widget.TextSegment{
|
||||
Text: " · ",
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
&widget.TextSegment{
|
||||
Text: secondaryText,
|
||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||
},
|
||||
}
|
||||
|
||||
q.secondary.Refresh()
|
||||
}
|
||||
|
||||
func (q *quickSearchResult) Tapped(_ *fyne.PointEvent) {
|
||||
q.parent.onSelected(q.index)
|
||||
}
|
||||
|
||||
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 *quickSearchResult) Refresh() {
|
||||
q.BaseWidget.Refresh()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -23,6 +23,7 @@ import (
|
||||
var (
|
||||
ShortcutReload = desktop.CustomShortcut{KeyName: fyne.KeyR, Modifier: os.ControlModifier}
|
||||
ShortcutSearch = desktop.CustomShortcut{KeyName: fyne.KeyF, Modifier: os.ControlModifier}
|
||||
ShortcutQuickSearch = desktop.CustomShortcut{KeyName: fyne.KeyG, Modifier: os.ControlModifier}
|
||||
ShortcutCloseWindow = desktop.CustomShortcut{KeyName: fyne.KeyW, Modifier: os.ControlModifier}
|
||||
|
||||
ShortcutNavOne = desktop.CustomShortcut{KeyName: fyne.Key1, Modifier: os.ControlModifier}
|
||||
@@ -53,10 +54,9 @@ type MainWindow struct {
|
||||
|
||||
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
|
||||
m := MainWindow{
|
||||
App: app,
|
||||
Window: fyneApp.NewWindow(displayAppName),
|
||||
BrowsingPane: browsing.NewBrowsingPane(app),
|
||||
theme: theme.NewMyTheme(&app.Config.Theme, configdir.LocalConfig(appName, "themes")),
|
||||
App: app,
|
||||
Window: fyneApp.NewWindow(displayAppName),
|
||||
theme: theme.NewMyTheme(&app.Config.Theme, configdir.LocalConfig(appName, "themes")),
|
||||
}
|
||||
|
||||
m.theme.NormalFont = app.Config.Application.FontNormalTTF
|
||||
@@ -71,6 +71,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
||||
MainWindow: m.Window,
|
||||
App: app,
|
||||
}
|
||||
m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller)
|
||||
m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane)
|
||||
// inject controller dependencies
|
||||
m.Controller.NavHandler = m.Router.NavigateTo
|
||||
@@ -249,10 +250,19 @@ func (m *MainWindow) addShortcuts() {
|
||||
m.BrowsingPane.Reload()
|
||||
})
|
||||
m.Canvas().AddShortcut(&ShortcutSearch, func(_ fyne.Shortcut) {
|
||||
if m.Controller.HaveModal() {
|
||||
// Do not focus search widget behind modal dialog
|
||||
return
|
||||
}
|
||||
if s := m.BrowsingPane.GetSearchBarIfAny(); s != nil {
|
||||
m.Window.Canvas().Focus(s)
|
||||
}
|
||||
})
|
||||
m.Canvas().AddShortcut(&ShortcutQuickSearch, func(_ fyne.Shortcut) {
|
||||
if !m.Controller.HaveModal() {
|
||||
m.Controller.ShowQuickSearch()
|
||||
}
|
||||
})
|
||||
m.Canvas().AddShortcut(&fyne.ShortcutSelectAll{}, func(_ fyne.Shortcut) {
|
||||
m.BrowsingPane.SelectAll()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ThumbnailLoader is a utility type that exposes a single API to load
|
||||
// a cover thumbnail by ID. If the image is immediately available in
|
||||
// the cache, OnLoaded will be called immediately. If it is not,
|
||||
// OnBeforeLoad will be called first, then OnLoaded will be called async
|
||||
// once the image is available.
|
||||
// Any subsequent calls to Load will cancel the previous load if not yet completed.
|
||||
type ThumbnailLoader struct {
|
||||
prevLoadCancel context.CancelFunc
|
||||
im ImageFetcher
|
||||
|
||||
OnBeforeLoad func()
|
||||
OnLoaded func(image.Image)
|
||||
}
|
||||
|
||||
// Image backend interface for the ThumbnailLoader
|
||||
// impl: backend.ImageManager
|
||||
type ImageFetcher interface {
|
||||
GetCoverThumbnailFromCache(string) (image.Image, bool)
|
||||
GetCoverThumbnailAsync(string, func(image.Image, error)) context.CancelFunc
|
||||
}
|
||||
|
||||
func NewThumbnailLoader(im ImageFetcher, onLoaded func(image.Image)) ThumbnailLoader {
|
||||
return ThumbnailLoader{im: im, OnLoaded: onLoaded}
|
||||
}
|
||||
|
||||
func (i *ThumbnailLoader) Load(coverID string) {
|
||||
if i.prevLoadCancel != nil {
|
||||
i.prevLoadCancel()
|
||||
}
|
||||
if coverID == "" {
|
||||
i.callOnLoaded(nil)
|
||||
return
|
||||
}
|
||||
if img, ok := i.im.GetCoverThumbnailFromCache(coverID); ok {
|
||||
i.callOnLoaded(img)
|
||||
return
|
||||
}
|
||||
if i.OnBeforeLoad != nil {
|
||||
i.OnBeforeLoad()
|
||||
}
|
||||
i.prevLoadCancel = i.im.GetCoverThumbnailAsync(coverID, func(img image.Image, err error) {
|
||||
if err != nil {
|
||||
log.Printf("Error loading cover image: %s", err.Error())
|
||||
} else {
|
||||
i.callOnLoaded(img)
|
||||
}
|
||||
i.prevLoadCancel() // Done. Release resources associated with un-cancelled ctx
|
||||
})
|
||||
}
|
||||
|
||||
func (i *ThumbnailLoader) callOnLoaded(im image.Image) {
|
||||
if i.OnLoaded != nil {
|
||||
i.OnLoaded(im)
|
||||
}
|
||||
}
|
||||
+7
-32
@@ -2,12 +2,11 @@ package widgets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
@@ -37,11 +36,6 @@ func (b *BatchingIterator) NextN(n int) []*mediaprovider.Album {
|
||||
return results
|
||||
}
|
||||
|
||||
type ImageFetcher interface {
|
||||
GetCoverThumbnailFromCache(string) (image.Image, bool)
|
||||
GetCoverThumbnailAsync(string, func(image.Image, error)) context.CancelFunc
|
||||
}
|
||||
|
||||
type GridViewIterator interface {
|
||||
NextN(int) []GridViewItemModel
|
||||
}
|
||||
@@ -82,7 +76,7 @@ type GridView struct {
|
||||
type GridViewState struct {
|
||||
items []GridViewItemModel
|
||||
iter GridViewIterator
|
||||
imageFetcher ImageFetcher
|
||||
imageFetcher util.ImageFetcher
|
||||
Placeholder fyne.Resource
|
||||
highestShown int
|
||||
done bool
|
||||
@@ -99,7 +93,7 @@ type GridViewState struct {
|
||||
|
||||
var _ fyne.Widget = (*GridView)(nil)
|
||||
|
||||
func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
func NewFixedGridView(items []GridViewItemModel, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
g := &GridView{
|
||||
GridViewState: GridViewState{
|
||||
items: items,
|
||||
@@ -113,7 +107,7 @@ func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder
|
||||
return g
|
||||
}
|
||||
|
||||
func NewGridView(iter GridViewIterator, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
func NewGridView(iter GridViewIterator, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
g := &GridView{
|
||||
GridViewState: GridViewState{
|
||||
iter: iter,
|
||||
@@ -203,6 +197,8 @@ func (g *GridView) createGridWrap() {
|
||||
// create func
|
||||
func() fyne.CanvasObject {
|
||||
card := NewGridViewItem(g.Placeholder)
|
||||
card.ImgLoader = util.NewThumbnailLoader(g.imageFetcher, card.Cover.SetImage)
|
||||
card.ImgLoader.OnBeforeLoad = func() { card.Cover.SetImage(nil) }
|
||||
card.OnPlay = func() { g.onPlay(card.ItemID(), false) }
|
||||
card.OnShowSecondaryPage = func(id string) {
|
||||
if g.OnShowSecondaryPage != nil {
|
||||
@@ -245,28 +241,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
||||
return
|
||||
}
|
||||
card.Update(item)
|
||||
// cancel any previous image fetch (no issues with possible double-invocations)
|
||||
if card.ImgLoadCancel != nil {
|
||||
card.ImgLoadCancel()
|
||||
}
|
||||
if item.CoverArtID != "" {
|
||||
if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
|
||||
card.Cover.SetImage(img)
|
||||
} else {
|
||||
card.Cover.SetImage(nil)
|
||||
card.ImgLoadCancel = g.imageFetcher.GetCoverThumbnailAsync(item.CoverArtID, func(i image.Image, err error) {
|
||||
if err == nil {
|
||||
card.Cover.SetImage(i)
|
||||
} else {
|
||||
log.Printf("error fetching image: %s", err.Error())
|
||||
}
|
||||
card.ImgLoadCancel() // done. release resources associated with cancel channel
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// use the placeholder for an item that has no cover art ID
|
||||
card.Cover.SetImage(nil)
|
||||
}
|
||||
card.ImgLoader.Load(item.CoverArtID)
|
||||
|
||||
// if user has scrolled near the bottom, fetch more
|
||||
if itemIdx > g.lenItems()-10 {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package widgets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
@@ -140,8 +140,8 @@ type GridViewItem struct {
|
||||
container *fyne.Container
|
||||
|
||||
// updated by GridView
|
||||
Cover *coverImage
|
||||
ImgLoadCancel context.CancelFunc
|
||||
Cover *coverImage
|
||||
ImgLoader util.ThumbnailLoader
|
||||
|
||||
OnPlay func()
|
||||
OnShowContextMenu func(fyne.Position)
|
||||
|
||||
@@ -21,6 +21,12 @@ type SearchEntry struct {
|
||||
func NewSearchEntry() *SearchEntry {
|
||||
sf := &SearchEntry{}
|
||||
sf.ExtendBaseWidget(sf)
|
||||
sf.Init()
|
||||
return sf
|
||||
}
|
||||
|
||||
// For use only by extending widgets
|
||||
func (sf *SearchEntry) Init() {
|
||||
sf.PlaceHolder = "Search"
|
||||
sf.ActionItem = NewClearTextButton(func() {
|
||||
sf.SetText("")
|
||||
@@ -34,7 +40,6 @@ func NewSearchEntry() *SearchEntry {
|
||||
}
|
||||
debounceFunc()
|
||||
}
|
||||
return sf
|
||||
}
|
||||
|
||||
func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) {
|
||||
|
||||
Reference in New Issue
Block a user