From f789296944302561484e2c442e70a048fb520571 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 16 Feb 2024 08:33:24 -0800 Subject: [PATCH] refactor and add focus navigation to genres list --- ui/browsing/genrespage.go | 64 +++++------ ui/browsing/playlistspage.go | 4 +- ui/widgets/albumfilterbutton.go | 4 +- ui/widgets/disabledlist.go | 33 ------ ui/widgets/focuslist.go | 195 ++++++++++++++++++++++++++++++++ ui/widgets/listrowbase.go | 115 ------------------- ui/widgets/tracklist.go | 50 +++----- 7 files changed, 244 insertions(+), 221 deletions(-) delete mode 100644 ui/widgets/disabledlist.go create mode 100644 ui/widgets/focuslist.go delete mode 100644 ui/widgets/listrowbase.go diff --git a/ui/browsing/genrespage.go b/ui/browsing/genrespage.go index f753066..882a174 100644 --- a/ui/browsing/genrespage.go +++ b/ui/browsing/genrespage.go @@ -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) -} diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index d952e37..2f2dd45 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -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) }, diff --git a/ui/widgets/albumfilterbutton.go b/ui/widgets/albumfilterbutton.go index ab4282f..70e5d5f 100644 --- a/ui/widgets/albumfilterbutton.go +++ b/ui/widgets/albumfilterbutton.go @@ -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() diff --git a/ui/widgets/disabledlist.go b/ui/widgets/disabledlist.go deleted file mode 100644 index a09dc67..0000000 --- a/ui/widgets/disabledlist.go +++ /dev/null @@ -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() {} diff --git a/ui/widgets/focuslist.go b/ui/widgets/focuslist.go new file mode 100644 index 0000000..af3ec49 --- /dev/null +++ b/ui/widgets/focuslist.go @@ -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), + ) +} diff --git a/ui/widgets/listrowbase.go b/ui/widgets/listrowbase.go deleted file mode 100644 index 03c5726..0000000 --- a/ui/widgets/listrowbase.go +++ /dev/null @@ -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), - ) -} diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index c7fb2d2..bea974b 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -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) } }