From 7e8a4d6d37b40b7a19cf997059572f403d5cfddc Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 2 Feb 2024 08:44:39 -0800 Subject: [PATCH] Updates #22: add focus arrow key navigation to grid view --- ui/util/util.go | 9 ++++ ui/widgets/gridview.go | 108 ++++++++++++++++++++++++++++--------- ui/widgets/gridviewitem.go | 61 ++++++++++++++++++++- 3 files changed, 152 insertions(+), 26 deletions(-) diff --git a/ui/util/util.go b/ui/util/util.go index 81e90ad..f00c4ef 100644 --- a/ui/util/util.go +++ b/ui/util/util.go @@ -3,6 +3,7 @@ package util import ( "fmt" "image" + "image/color" "math" "strconv" "strings" @@ -24,6 +25,14 @@ import ( var BoldRichTextStyle = widget.RichTextStyle{TextStyle: fyne.TextStyle{Bold: true}, Inline: true} +func MakeOpaque(c color.Color) color.Color { + if nrgba, ok := c.(color.NRGBA); ok { + nrgba.A = 255 + return nrgba + } + return c +} + func SecondsToTimeString(s float64) string { if s < 0 { s = 0 diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 9f81ae7..43f1fd0 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -2,6 +2,7 @@ package widgets import ( "context" + "math" "sync" "github.com/dweymouth/supersonic/backend/mediaprovider" @@ -73,6 +74,9 @@ type GridView struct { grid *disabledGridWrap menu *widget.PopUpMenu menuGridViewItemId string + itemForIndex map[int]*GridViewItem + itemWidth float32 + numColsCached int } type GridViewState struct { @@ -103,6 +107,8 @@ func NewFixedGridView(items []GridViewItemModel, fetch util.ImageFetcher, placeh imageFetcher: fetch, Placeholder: placeholder, }, + itemWidth: NewGridViewItem(nil).MinSize().Width, + itemForIndex: make(map[int]*GridViewItem), } g.ExtendBaseWidget(g) g.createGridWrap() @@ -116,6 +122,8 @@ func NewGridView(iter GridViewIterator, fetch util.ImageFetcher, placeholder fyn imageFetcher: fetch, Placeholder: placeholder, }, + itemWidth: NewGridViewItem(nil).MinSize().Width, + itemForIndex: make(map[int]*GridViewItem), } g.ExtendBaseWidget(g) g.createGridWrap() @@ -191,41 +199,91 @@ func (g *GridView) ScrollToOffset(offs float32) { g.grid.ScrollToOffset(offs) } +func (g *GridView) Resize(size fyne.Size) { + g.numColsCached = -1 + g.BaseWidget.Resize(size) +} + +func (g *GridView) numCols() int { + if g.numColsCached == -1 { + // logic here taken from gridwrap.go in Fyne codebase + colCount := 1 + width := g.Size().Width + if width > g.itemWidth { + pad := theme.Padding() + colCount = int(math.Floor(float64(width+pad) / float64(g.itemWidth+pad))) + } + g.numColsCached = colCount + } + return g.numColsCached +} + func (g *GridView) createGridWrap() { g.grid = NewDisabledGridWrap( - func() int { - return g.lenItems() - }, - // 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 { - g.OnShowSecondaryPage(id) - } - } - card.OnShowItemPage = func() { - if g.OnShowItemPage != nil { - g.OnShowItemPage(card.ItemID()) - } - } - card.OnShowContextMenu = func(p fyne.Position) { - g.showContextMenu(card, p) - } - return card - }, + g.lenItems, + g.createNewItemCard, // update func func(itemID widget.GridWrapItemID, obj fyne.CanvasObject) { ac := obj.(*GridViewItem) - g.doUpdateItemCard(int(itemID), ac) + if itemID != ac.ItemIndex { + g.doUpdateItemCard(int(itemID), ac) + } // else nothing to do }, ) } +func (g *GridView) createNewItemCard() fyne.CanvasObject { + card := NewGridViewItem(g.Placeholder) + card.ItemIndex = -1 + 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 { + g.OnShowSecondaryPage(id) + } + } + card.OnShowItemPage = func() { + if g.OnShowItemPage != nil { + g.OnShowItemPage(card.ItemID()) + } + } + card.OnShowContextMenu = func(p fyne.Position) { + g.showContextMenu(card, p) + } + card.OnFocusNeighbor = func(neighbor int) { + focusIndex := -1 + switch neighbor { + case 0: // left + focusIndex = card.ItemIndex - 1 + case 1: // right + focusIndex = card.ItemIndex + 1 + case 2: // up + focusIndex = card.ItemIndex - g.numCols() + case 3: // down + focusIndex = card.ItemIndex + g.numCols() + } + if focusIndex >= 0 && focusIndex < g.lenItems() { + g.grid.ScrollTo(focusIndex) + g.stateMutex.RLock() + if item, ok := g.itemForIndex[focusIndex]; ok { + fyne.CurrentApp().Driver().CanvasForObject(g).Focus(item) + } + g.stateMutex.RUnlock() + } + } + return card +} + func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { + g.stateMutex.Lock() + if c, ok := g.itemForIndex[card.ItemIndex]; ok && c == card { + delete(g.itemForIndex, card.ItemIndex) + } + card.ItemIndex = itemIdx + g.itemForIndex[itemIdx] = card + g.stateMutex.Unlock() + if itemIdx > g.highestShown { g.highestShown = itemIdx } diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index d928550..e4511fc 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -2,6 +2,7 @@ package widgets import ( "image" + "image/color" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/sharedutil" @@ -12,10 +13,12 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) var _ fyne.Widget = (*GridViewItem)(nil) +var _ fyne.Focusable = (*GridViewItem)(nil) var _ fyne.Widget = (*coverImage)(nil) @@ -138,15 +141,21 @@ type GridViewItem struct { primaryText *widget.Hyperlink secondaryText *MultiHyperlink container *fyne.Container + focused bool + focusRect *canvas.Rectangle // updated by GridView Cover *coverImage ImgLoader util.ThumbnailLoader + ItemIndex int OnPlay func() OnShowContextMenu func(fyne.Position) OnShowItemPage func() OnShowSecondaryPage func(string) + + // Invoked with arg 0-3 when left, right, up, or down neighbor should be focused, respectively + OnFocusNeighbor func(int) } func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { @@ -187,7 +196,10 @@ func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { func (g *GridViewItem) createContainer() { info := container.New(&layouts.VboxCustomPadding{ExtraPad: -16}, g.primaryText, g.secondaryText) - c := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, g.Cover, info) + g.focusRect = canvas.NewRectangle(color.Transparent) + g.focusRect.StrokeWidth = 3 + coverStack := container.NewStack(g.Cover, g.focusRect) + c := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, coverStack, info) pad := &layouts.CenterPadLayout{PadLeftRight: 20, PadTopBottom: 10} g.container = container.New(pad, c) } @@ -203,9 +215,15 @@ func (g *GridViewItem) Update(model GridViewItemModel) { g.secondaryText.BuildSegments(model.Secondary, model.SecondaryIDs) g.secondaryText.Refresh() g.Cover.ResetPlayButton() + if g.focused { + fyne.CurrentApp().Driver().CanvasForObject(g).Focus(nil) + g.FocusLost() + } } func (g *GridViewItem) Refresh() { + g.focusRect.StrokeColor = util.MakeOpaque(theme.FocusColor()) + g.focusRect.Hidden = !g.focused g.BaseWidget.Refresh() } @@ -213,6 +231,47 @@ func (g *GridViewItem) ItemID() string { return g.itemID } +func (g *GridViewItem) FocusGained() { + g.focused = true + g.Refresh() +} + +func (g *GridViewItem) FocusLost() { + g.focused = false + g.Refresh() +} + +func (g *GridViewItem) TypedKey(e *fyne.KeyEvent) { + if !g.focused { + return + } + focusArg := -1 + switch e.Name { + case fyne.KeyLeft: + focusArg = 0 + case fyne.KeyRight: + focusArg = 1 + case fyne.KeyUp: + focusArg = 2 + case fyne.KeyDown: + focusArg = 3 + case fyne.KeyEnter: + fallthrough + case fyne.KeySpace: + if g.OnShowItemPage != nil { + g.OnShowItemPage() + return + } + } + if focusArg >= 0 && g.OnFocusNeighbor != nil { + g.OnFocusNeighbor(focusArg) + } +} + +func (g *GridViewItem) TypedRune(rune) { + // intentionally blank +} + func (g *GridViewItem) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(g.container) }