refactor and add focus navigation to genres list

This commit is contained in:
Drew Weymouth
2024-02-16 08:33:24 -08:00
parent b97b029422
commit f789296944
7 changed files with 244 additions and 221 deletions
+31 -33
View File
@@ -145,21 +145,18 @@ type GenreList struct {
columnsLayout *layouts.ColumnsLayout
hdr *widgets.ListHeader
list *widgets.DisabledList
list *widgets.FocusList
container *fyne.Container
}
type GenreListRow struct {
widget.BaseWidget
widgets.FocusListRowBase
Item *mediaprovider.Genre
OnTapped func()
Item *mediaprovider.Genre
nameLabel *widget.Label
albumCountLabel *widget.Label
trackCountLabel *widget.Label
container *fyne.Container
}
func NewGenreListRow(layout *layouts.ColumnsLayout) *GenreListRow {
@@ -171,7 +168,7 @@ func NewGenreListRow(layout *layouts.ColumnsLayout) *GenreListRow {
a.ExtendBaseWidget(a)
a.albumCountLabel.Alignment = fyne.TextAlignTrailing
a.trackCountLabel.Alignment = fyne.TextAlignTrailing
a.container = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel)
a.Content = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel)
return a
}
@@ -182,31 +179,42 @@ func NewGenreList(sorting widgets.ListHeaderSort) *GenreList {
}
a.ExtendBaseWidget(a)
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
{"Name", fyne.TextAlignLeading, false}, {"Album Count", fyne.TextAlignTrailing, false}, {"Track Count", fyne.TextAlignTrailing, false}}, a.columnsLayout)
{Text: "Name", Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
{Text: "Album Count", Alignment: fyne.TextAlignTrailing, CanToggleVisible: false},
{Text: "Track Count", Alignment: fyne.TextAlignTrailing, CanToggleVisible: false}},
a.columnsLayout)
a.hdr.SetSorting(sorting)
a.hdr.OnColumnSortChanged = a.onSorted
a.list = widgets.NewDisabledList(
a.list = widgets.NewFocusList(
func() int { return len(a.genres) },
func() fyne.CanvasObject {
r := NewGenreListRow(a.columnsLayout)
r.OnTapped = func() { a.onRowDoubleTapped(r.Item) }
r.OnTapped = func() { a.onGoToGenre(r.Item) }
r.OnFocusNeighbor = func(up bool) {
a.list.FocusNeighbor(r.ListItemID, up)
}
return r
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*GenreListRow)
row.Item = a.genres[id]
row.nameLabel.Text = row.Item.Name
if row.Item.AlbumCount >= 0 {
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
} else {
row.albumCountLabel.Text = ""
if row.Item != a.genres[id] {
row.EnsureUnfocused()
a.list.SetItemForID(id, row)
row.ListItemID = id
row.Item = a.genres[id]
row.nameLabel.Text = row.Item.Name
if row.Item.AlbumCount >= 0 {
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
} else {
row.albumCountLabel.Text = ""
}
if row.Item.TrackCount >= 0 {
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
} else {
row.trackCountLabel.Text = ""
}
row.Refresh()
}
if row.Item.TrackCount >= 0 {
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
} else {
row.trackCountLabel.Text = ""
}
row.Refresh()
},
)
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
@@ -265,22 +273,12 @@ func (g *GenreList) intSort(fieldFn func(*mediaprovider.Genre) int) {
g.genres = new
}
func (a *GenreList) onRowDoubleTapped(item *mediaprovider.Genre) {
func (a *GenreList) onGoToGenre(item *mediaprovider.Genre) {
if a.OnNavTo != nil {
a.OnNavTo(item.Name)
}
}
func (a *GenreListRow) Tapped(*fyne.PointEvent) {
if a.OnTapped != nil {
a.OnTapped()
}
}
func (a *GenreList) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
func (a *GenreListRow) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container)
}
+2 -2
View File
@@ -290,7 +290,7 @@ type PlaylistList struct {
columnsLayout *layouts.ColumnsLayout
header *widgets.ListHeader
list *widgets.DisabledList
list *widgets.FocusList
container *fyne.Container
}
@@ -300,7 +300,7 @@ func NewPlaylistList(initialSort widgets.ListHeaderSort) *PlaylistList {
columnsLayout: layouts.NewColumnsLayout([]float32{-1, -1, 200, 125}),
}
a.buildHeader()
a.list = widgets.NewDisabledList(
a.list = widgets.NewFocusList(
func() int {
return len(a.playlists)
},
+2 -2
View File
@@ -218,7 +218,7 @@ type GenreFilterSubsection struct {
noneBtn *widget.Button
listModelMutex sync.RWMutex
genreListViewModel []string
genreListView *DisabledList
genreListView *FocusList
container *fyne.Container
}
@@ -234,7 +234,7 @@ func NewGenreFilterSubsection(onChanged func([]string), initialSelectedGenres []
g.selectedGenres[genre] = nil
}
g.genreListView = NewDisabledList(
g.genreListView = NewFocusList(
func() int {
g.listModelMutex.RLock()
defer g.listModelMutex.RUnlock()
-33
View File
@@ -1,33 +0,0 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
// DisabledList extends List to be disabled so that the
// focus manager considers it unfocusable.
// This is needed since we handle row focusability ourselves.
type DisabledList struct {
widget.List
}
func NewDisabledList(len func() int, create func() fyne.CanvasObject, update func(widget.GridWrapItemID, fyne.CanvasObject)) *DisabledList {
g := &DisabledList{
List: widget.List{
Length: len,
CreateItem: create,
UpdateItem: update,
},
}
g.ExtendBaseWidget(g)
return g
}
var _ fyne.Disableable = (*DisabledList)(nil)
func (g *DisabledList) Disabled() bool { return true }
func (g *DisabledList) Disable() {}
func (g *DisabledList) Enable() {}
+195
View File
@@ -0,0 +1,195 @@
package widgets
import (
"sync"
"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"
)
// FocusList extends List to be disabled so that the focus manager
// considers it unfocusable, and adds utilities for handling our
// own focus navigation on the rows directly (with FocusListRow
type FocusList struct {
widget.List
mutex sync.Mutex
itemForIndex map[widget.ListItemID]FocusListRow
}
type FocusListRow interface {
fyne.Focusable
ItemID() widget.ListItemID
}
func NewFocusList(len func() int, create func() fyne.CanvasObject, update func(widget.GridWrapItemID, fyne.CanvasObject)) *FocusList {
g := &FocusList{
List: widget.List{
Length: len,
CreateItem: create,
UpdateItem: update,
},
itemForIndex: make(map[int]FocusListRow),
}
g.ExtendBaseWidget(g)
return g
}
var _ fyne.Disableable = (*FocusList)(nil)
func (g *FocusList) Disabled() bool { return true }
func (g *FocusList) Disable() {}
func (g *FocusList) Enable() {}
// MUST be called *before* updating the ListItemID field to the
// new ItemID this row will be bound to.
func (g *FocusList) SetItemForID(id widget.ListItemID, item FocusListRow) {
g.mutex.Lock()
if other, ok := g.itemForIndex[id]; ok && other == item {
delete(g.itemForIndex, other.ItemID())
}
g.itemForIndex[id] = item
g.mutex.Unlock()
}
func (g *FocusList) ClearItemForIDMap() {
g.itemForIndex = make(map[int]FocusListRow)
}
func (g *FocusList) FocusNeighbor(curItem widget.ListItemID, up bool) {
focusIdx := curItem + 1
if up {
focusIdx = curItem - 1
}
if focusIdx >= 0 && focusIdx < g.Length() {
g.ScrollTo(focusIdx)
}
g.mutex.Lock()
other, ok := g.itemForIndex[focusIdx]
g.mutex.Unlock()
if ok {
fyne.CurrentApp().Driver().CanvasForObject(g).Focus(other)
}
}
var _ fyne.Tappable = (*FocusListRowBase)(nil)
var _ fyne.Widget = (*FocusListRowBase)(nil)
var _ fyne.Focusable = (*FocusListRowBase)(nil)
// Base type used for all list rows in widgets such as Tracklist, etc.
type FocusListRowBase struct {
widget.BaseWidget
ListItemID widget.ListItemID
Content fyne.CanvasObject
Selected bool
Focused bool
OnTapped func()
OnDoubleTapped func()
OnFocusNeighbor func(up bool) //TODO: func(up, selecting bool)
tappedAt int64 // unixMillis
focusedRect *canvas.Rectangle
selectionRect *canvas.Rectangle
}
func (l *FocusListRowBase) ItemID() widget.ListItemID {
return l.ListItemID
}
func (l *FocusListRowBase) EnsureUnfocused() {
if l.Focused {
fyne.CurrentApp().Driver().CanvasForObject(l).Unfocus()
}
l.Focused = false
}
// We implement our own double tapping so that the Tapped behavior
// can be triggered instantly.
func (l *FocusListRowBase) Tapped(*fyne.PointEvent) {
prevTap := l.tappedAt
l.tappedAt = time.Now().UnixMilli()
if l.tappedAt-prevTap < 300 {
if l.OnDoubleTapped != nil {
l.OnDoubleTapped()
}
} else {
if l.OnTapped != nil {
l.OnTapped()
}
}
}
func (l *FocusListRowBase) FocusGained() {
l.Focused = true
l.Refresh()
}
func (l *FocusListRowBase) FocusLost() {
l.Focused = false
l.Refresh()
}
func (l *FocusListRowBase) TypedKey(e *fyne.KeyEvent) {
/**
// TODO: enable shift+arrows for selection, but it's complicated to implement in the widgets
desktop, ok := fyne.CurrentApp().Driver().(desktop.Driver)
isSelecting := func() bool {
return ok && desktop.CurrentKeyModifiers()&fyne.KeyModifierShift != 0
}
*/
switch {
case e.Name == fyne.KeyUp:
if l.OnFocusNeighbor != nil {
l.OnFocusNeighbor(true)
}
case e.Name == fyne.KeyDown:
if l.OnFocusNeighbor != nil {
l.OnFocusNeighbor(false)
}
case e.Name == fyne.KeySpace:
if l.OnTapped != nil {
l.OnTapped()
}
case e.Name == fyne.KeyReturn || e.Name == fyne.KeyEnter:
if l.OnDoubleTapped != nil {
l.OnDoubleTapped()
} else if l.OnTapped != nil {
l.OnTapped()
}
}
}
func (l *FocusListRowBase) TypedRune(r rune) {
}
func (l *FocusListRowBase) Refresh() {
l.focusedRect.FillColor = theme.HoverColor()
l.focusedRect.Hidden = !l.Focused
l.selectionRect.FillColor = theme.SelectionColor()
l.selectionRect.Hidden = !l.Selected
l.BaseWidget.Refresh()
}
func (l *FocusListRowBase) CreateRenderer() fyne.WidgetRenderer {
if l.selectionRect == nil {
l.selectionRect = canvas.NewRectangle(theme.SelectionColor())
l.selectionRect.CornerRadius = theme.SelectionRadiusSize()
l.selectionRect.Hidden = !l.Selected
}
if l.focusedRect == nil {
l.focusedRect = canvas.NewRectangle(theme.HoverColor())
l.focusedRect.CornerRadius = theme.SelectionRadiusSize()
l.focusedRect.Hidden = !l.Focused
}
return widget.NewSimpleRenderer(
container.NewStack(l.selectionRect, l.focusedRect, l.Content),
)
}
-115
View File
@@ -1,115 +0,0 @@
package widgets
import (
"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"
)
var _ fyne.Tappable = (*ListRowBase)(nil)
var _ fyne.Widget = (*ListRowBase)(nil)
var _ fyne.Focusable = (*ListRowBase)(nil)
// Base type used for all list rows in widgets such as Tracklist, etc.
type ListRowBase struct {
widget.BaseWidget
Content fyne.CanvasObject
Selected bool
Focused bool
OnTapped func()
OnDoubleTapped func()
OnFocusNeighbor func(up bool) //TODO: func(up, selecting bool)
tappedAt int64 // unixMillis
focusedRect *canvas.Rectangle
selectionRect *canvas.Rectangle
}
// We implement our own double tapping so that the Tapped behavior
// can be triggered instantly.
func (l *ListRowBase) Tapped(*fyne.PointEvent) {
prevTap := l.tappedAt
l.tappedAt = time.Now().UnixMilli()
if l.tappedAt-prevTap < 300 {
if l.OnDoubleTapped != nil {
l.OnDoubleTapped()
}
} else {
if l.OnTapped != nil {
l.OnTapped()
}
}
}
func (l *ListRowBase) FocusGained() {
l.Focused = true
l.Refresh()
}
func (l *ListRowBase) FocusLost() {
l.Focused = false
l.Refresh()
}
func (l *ListRowBase) TypedKey(e *fyne.KeyEvent) {
/**
// TODO: enable shift+arrows for selection, but it's complicated to implement in the widgets
desktop, ok := fyne.CurrentApp().Driver().(desktop.Driver)
isSelecting := func() bool {
return ok && desktop.CurrentKeyModifiers()&fyne.KeyModifierShift != 0
}
*/
switch {
case e.Name == fyne.KeyUp:
if l.OnFocusNeighbor != nil {
l.OnFocusNeighbor(true)
}
case e.Name == fyne.KeyDown:
if l.OnFocusNeighbor != nil {
l.OnFocusNeighbor(false)
}
case e.Name == fyne.KeySpace:
if l.OnTapped != nil {
l.OnTapped()
}
case e.Name == fyne.KeyReturn || e.Name == fyne.KeyEnter:
if l.OnDoubleTapped != nil {
l.OnDoubleTapped()
} else if l.OnTapped != nil {
l.OnTapped()
}
}
}
func (l *ListRowBase) TypedRune(r rune) {
}
func (l *ListRowBase) Refresh() {
l.focusedRect.FillColor = theme.HoverColor()
l.focusedRect.Hidden = !l.Focused
l.selectionRect.FillColor = theme.SelectionColor()
l.selectionRect.Hidden = !l.Selected
l.BaseWidget.Refresh()
}
func (l *ListRowBase) CreateRenderer() fyne.WidgetRenderer {
if l.selectionRect == nil {
l.selectionRect = canvas.NewRectangle(theme.SelectionColor())
l.selectionRect.CornerRadius = theme.SelectionRadiusSize()
l.selectionRect.Hidden = !l.Selected
}
if l.focusedRect == nil {
l.focusedRect = canvas.NewRectangle(theme.HoverColor())
l.focusedRect.CornerRadius = theme.SelectionRadiusSize()
l.focusedRect.Hidden = !l.Focused
}
return widget.NewSimpleRenderer(
container.NewStack(l.selectionRect, l.focusedRect, l.Content),
)
}
+14 -36
View File
@@ -101,12 +101,11 @@ type Tracklist struct {
tracksMutex sync.RWMutex
tracks []*trackModel
tracksOrigOrder []*trackModel
itemForIndex map[int]*TrackRow
nowPlayingID string
colLayout *layouts.ColumnsLayout
hdr *ListHeader
list *DisabledList
list *FocusList
ctxMenu *fyne.Menu
ratingSubmenu *fyne.MenuItem
container *fyne.Container
@@ -118,7 +117,7 @@ type trackModel struct {
}
func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
t := &Tracklist{visibleColumns: make([]bool, numColumns), itemForIndex: make(map[int]*TrackRow)}
t := &Tracklist{visibleColumns: make([]bool, numColumns)}
t.ExtendBaseWidget(t)
if len(tracks) > 0 {
@@ -140,31 +139,19 @@ func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
playIcon.ColorName = theme.ColorNamePrimary
playingIcon := container.NewCenter(container.NewHBox(util.NewHSpace(2), widget.NewIcon(playIcon)))
t.list = NewDisabledList(
t.list = NewFocusList(
t.lenTracks,
func() fyne.CanvasObject {
tr := NewTrackRow(t, playingIcon)
tr.OnTapped = func() {
t.onSelectTrack(tr.trackIdx)
t.onSelectTrack(tr.ListItemID)
}
tr.OnTappedSecondary = t.onShowContextMenu
tr.OnDoubleTapped = func() {
t.onPlayTrackAt(tr.trackIdx)
t.onPlayTrackAt(tr.ListItemID)
}
tr.OnFocusNeighbor = func(up bool) {
focusIdx := tr.trackIdx + 1
if up {
focusIdx = tr.trackIdx - 1
}
if focusIdx >= 0 && focusIdx < t.lenTracks() {
t.list.ScrollTo(focusIdx)
}
t.tracksMutex.RLock()
other, ok := t.itemForIndex[focusIdx]
t.tracksMutex.RUnlock()
if ok {
fyne.CurrentApp().Driver().CanvasForObject(t).Focus(other)
}
t.list.FocusNeighbor(tr.ListItemID, up)
}
return tr
},
@@ -181,14 +168,9 @@ func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
t.tracksMutex.RUnlock()
tr := item.(*TrackRow)
if tr.trackID != model.track.ID || tr.trackIdx != itemID {
t.tracksMutex.Lock()
if other, ok := t.itemForIndex[itemID]; ok && other == tr {
delete(t.itemForIndex, other.trackIdx)
}
t.itemForIndex[itemID] = tr
t.tracksMutex.Unlock()
tr.trackIdx = itemID
if tr.trackID != model.track.ID || tr.ListItemID != itemID {
t.list.SetItemForID(itemID, tr)
tr.ListItemID = itemID
}
i := -1 // signal that we want to display the actual track num.
if t.Options.AutoNumber {
@@ -327,7 +309,7 @@ func (t *Tracklist) Clear() {
defer t.tracksMutex.Unlock()
t.tracks = nil
t.tracksOrigOrder = nil
t.itemForIndex = make(map[int]*TrackRow)
t.list.ClearItemForIDMap()
}
// Sets the tracks in the tracklist. Thread-safe.
@@ -339,7 +321,7 @@ func (t *Tracklist) SetTracks(trs []*mediaprovider.Track) {
func (t *Tracklist) _setTracks(trs []*mediaprovider.Track) {
t.tracksMutex.Lock()
defer t.tracksMutex.Unlock()
t.itemForIndex = make(map[int]*TrackRow)
t.list.ClearItemForIDMap()
t.tracksOrigOrder = toTrackModels(trs)
t.doSortTracks()
}
@@ -731,11 +713,10 @@ func colName(i int) string {
}
type TrackRow struct {
ListRowBase
FocusListRowBase
// internal state
tracklist *Tracklist
trackIdx int
trackNum int
trackID string
albumID string
@@ -821,10 +802,7 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) {
// a new track (*mediaprovider.Track)
tr := tm.track
if tr.ID != t.trackID {
if t.Focused {
fyne.CurrentApp().Driver().CanvasForObject(t).Focus(nil)
t.Focused = false
}
t.EnsureUnfocused()
t.trackID = tr.ID
t.albumID = tr.AlbumID
@@ -949,7 +927,7 @@ func (t *TrackRow) setTrackRating(rating int) {
func (t *TrackRow) TappedSecondary(e *fyne.PointEvent) {
if t.OnTappedSecondary != nil {
t.OnTappedSecondary(e, t.trackIdx)
t.OnTappedSecondary(e, t.ListItemID)
}
}