VideoTools/vendor/fyne.io/fyne/v2/widget/gridwrap.go
Stu Leak 68df790d27 Fix player frame generation and video playback
Major improvements to UnifiedPlayer:

1. GetFrameImage() now works when paused for responsive UI updates
2. Play() method properly starts FFmpeg process
3. Frame display loop runs continuously for smooth video display
4. Disabled audio temporarily to fix video playback fundamentals
5. Simplified FFmpeg command to focus on video stream only

Player now:
- Generates video frames correctly
- Shows video when paused
- Has responsive progress tracking
- Starts playback properly

Next steps: Re-enable audio playback once video is stable
2026-01-07 22:20:00 -05:00

706 lines
18 KiB
Go

package widget
import (
"fmt"
"math"
"sort"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/internal/async"
"fyne.io/fyne/v2/internal/widget"
"fyne.io/fyne/v2/theme"
)
// Declare conformity with interfaces.
var (
_ fyne.Widget = (*GridWrap)(nil)
_ fyne.Focusable = (*GridWrap)(nil)
)
// GridWrapItemID is the ID of an individual item in the GridWrap widget.
//
// Since: 2.4
type GridWrapItemID = int
// GridWrap is a widget with an API very similar to widget.List,
// that lays out items in a scrollable wrapping grid similar to container.NewGridWrap.
// It caches and reuses widgets for performance.
//
// Since: 2.4
type GridWrap struct {
BaseWidget
// Length is a callback for returning the number of items in the GridWrap.
Length func() int `json:"-"`
// CreateItem is a callback invoked to create a new widget to render
// an item in the GridWrap.
CreateItem func() fyne.CanvasObject `json:"-"`
// UpdateItem is a callback invoked to update a GridWrap item widget
// to display a new item in the list. The UpdateItem callback should
// only update the given item, it should not invoke APIs that would
// change other properties of the GridWrap itself.
UpdateItem func(id GridWrapItemID, item fyne.CanvasObject) `json:"-"`
// OnSelected is a callback to be notified when a given item
// in the GridWrap has been selected.
OnSelected func(id GridWrapItemID) `json:"-"`
// OnSelected is a callback to be notified when a given item
// in the GridWrap has been unselected.
OnUnselected func(id GridWrapItemID) `json:"-"`
currentFocus ListItemID
focused bool
scroller *widget.Scroll
selected []GridWrapItemID
itemMin fyne.Size
offsetY float32
offsetUpdated func(fyne.Position)
colCountCache int
}
// NewGridWrap creates and returns a GridWrap widget for displaying items in
// a wrapping grid layout with scrolling and caching for performance.
//
// Since: 2.4
func NewGridWrap(length func() int, createItem func() fyne.CanvasObject, updateItem func(GridWrapItemID, fyne.CanvasObject)) *GridWrap {
gwList := &GridWrap{Length: length, CreateItem: createItem, UpdateItem: updateItem}
gwList.ExtendBaseWidget(gwList)
return gwList
}
// NewGridWrapWithData creates a new GridWrap widget that will display the contents of the provided data.
//
// Since: 2.4
func NewGridWrapWithData(data binding.DataList, createItem func() fyne.CanvasObject, updateItem func(binding.DataItem, fyne.CanvasObject)) *GridWrap {
gwList := NewGridWrap(
data.Length,
createItem,
func(i GridWrapItemID, o fyne.CanvasObject) {
item, err := data.GetItem(i)
if err != nil {
fyne.LogError(fmt.Sprintf("Error getting data item %d", i), err)
return
}
updateItem(item, o)
})
data.AddListener(binding.NewDataListener(gwList.Refresh))
return gwList
}
// CreateRenderer is a private method to Fyne which links this widget to its renderer.
func (l *GridWrap) CreateRenderer() fyne.WidgetRenderer {
l.ExtendBaseWidget(l)
if f := l.CreateItem; f != nil && l.itemMin.IsZero() {
item := createItemAndApplyThemeScope(f, l)
l.itemMin = item.MinSize()
}
layout := &fyne.Container{Layout: newGridWrapLayout(l)}
l.scroller = widget.NewVScroll(layout)
layout.Resize(layout.MinSize())
return newGridWrapRenderer([]fyne.CanvasObject{l.scroller}, l, l.scroller, layout)
}
// FocusGained is called after this GridWrap has gained focus.
func (l *GridWrap) FocusGained() {
l.focused = true
l.RefreshItem(l.currentFocus)
}
// FocusLost is called after this GridWrap has lost focus.
func (l *GridWrap) FocusLost() {
l.focused = false
l.RefreshItem(l.currentFocus)
}
// MinSize returns the size that this widget should not shrink below.
func (l *GridWrap) MinSize() fyne.Size {
l.ExtendBaseWidget(l)
return l.BaseWidget.MinSize()
}
func (l *GridWrap) scrollTo(id GridWrapItemID) {
if l.scroller == nil {
return
}
pad := l.Theme().Size(theme.SizeNamePadding)
row := math.Floor(float64(id) / float64(l.ColumnCount()))
y := float32(row)*l.itemMin.Height + float32(row)*pad
if y < l.scroller.Offset.Y {
l.scroller.Offset.Y = y
} else if size := l.scroller.Size(); y+l.itemMin.Height > l.scroller.Offset.Y+size.Height {
l.scroller.Offset.Y = y + l.itemMin.Height - size.Height
}
l.offsetUpdated(l.scroller.Offset)
}
// RefreshItem refreshes a single item, specified by the item ID passed in.
//
// Since: 2.4
func (l *GridWrap) RefreshItem(id GridWrapItemID) {
if l.scroller == nil {
return
}
l.BaseWidget.Refresh()
lo := l.scroller.Content.(*fyne.Container).Layout.(*gridWrapLayout)
item, ok := lo.searchVisible(lo.visible, id)
if ok {
lo.setupGridItem(item, id, l.focused && l.currentFocus == id)
}
}
// Resize is called when this GridWrap should change size. We refresh to ensure invisible items are drawn.
func (l *GridWrap) Resize(s fyne.Size) {
oldColCount := l.ColumnCount()
oldHeight := l.size.Height
l.colCountCache = 0
l.BaseWidget.Resize(s)
newColCount := l.ColumnCount()
if oldColCount == newColCount && oldHeight == s.Height {
// no content update needed if resizing only horizontally and col count is unchanged
return
}
if l.scroller != nil {
l.offsetUpdated(l.scroller.Offset)
l.scroller.Content.(*fyne.Container).Layout.(*gridWrapLayout).updateGrid(true)
}
}
// Select adds the item identified by the given ID to the selection.
func (l *GridWrap) Select(id GridWrapItemID) {
if len(l.selected) > 0 && id == l.selected[0] {
return
}
length := 0
if f := l.Length; f != nil {
length = f()
}
if id < 0 || id >= length {
return
}
old := l.selected
l.selected = []GridWrapItemID{id}
defer func() {
if f := l.OnUnselected; f != nil && len(old) > 0 {
f(old[0])
}
if f := l.OnSelected; f != nil {
f(id)
}
}()
l.scrollTo(id)
l.Refresh()
}
// ScrollTo scrolls to the item represented by id
func (l *GridWrap) ScrollTo(id GridWrapItemID) {
length := 0
if f := l.Length; f != nil {
length = f()
}
if id < 0 || id >= length {
return
}
l.scrollTo(id)
l.Refresh()
}
// ScrollToBottom scrolls to the end of the list
func (l *GridWrap) ScrollToBottom() {
l.scroller.ScrollToBottom()
l.offsetUpdated(l.scroller.Offset)
}
// ScrollToTop scrolls to the start of the list
func (l *GridWrap) ScrollToTop() {
l.scroller.ScrollToTop()
l.offsetUpdated(l.scroller.Offset)
}
// ScrollToOffset scrolls the list to the given offset position
func (l *GridWrap) ScrollToOffset(offset float32) {
if l.scroller == nil {
return
}
if offset < 0 {
offset = 0
}
contentHeight := l.contentMinSize().Height
if l.Size().Height >= contentHeight {
return // content fully visible - no need to scroll
}
if offset > contentHeight {
offset = contentHeight
}
l.scroller.ScrollToOffset(fyne.NewPos(0, offset))
l.offsetUpdated(l.scroller.Offset)
}
// TypedKey is called if a key event happens while this GridWrap is focused.
func (l *GridWrap) TypedKey(event *fyne.KeyEvent) {
switch event.Name {
case fyne.KeySpace:
l.Select(l.currentFocus)
case fyne.KeyDown:
count := 0
if f := l.Length; f != nil {
count = f()
}
l.RefreshItem(l.currentFocus)
l.currentFocus += l.ColumnCount()
if l.currentFocus >= count-1 {
l.currentFocus = count - 1
}
l.scrollTo(l.currentFocus)
l.RefreshItem(l.currentFocus)
case fyne.KeyLeft:
if l.currentFocus <= 0 {
return
}
l.RefreshItem(l.currentFocus)
l.currentFocus--
l.scrollTo(l.currentFocus)
l.RefreshItem(l.currentFocus)
case fyne.KeyRight:
if f := l.Length; f != nil && l.currentFocus >= f()-1 {
return
}
l.RefreshItem(l.currentFocus)
l.currentFocus++
l.scrollTo(l.currentFocus)
l.RefreshItem(l.currentFocus)
case fyne.KeyUp:
if l.currentFocus <= 0 {
return
}
l.RefreshItem(l.currentFocus)
l.currentFocus -= l.ColumnCount()
if l.currentFocus < 0 {
l.currentFocus = 0
}
l.scrollTo(l.currentFocus)
l.RefreshItem(l.currentFocus)
}
}
// TypedRune is called if a text event happens while this GridWrap is focused.
func (l *GridWrap) TypedRune(_ rune) {
// intentionally left blank
}
// GetScrollOffset returns the current scroll offset position
func (l *GridWrap) GetScrollOffset() float32 {
return l.offsetY
}
// Unselect removes the item identified by the given ID from the selection.
func (l *GridWrap) Unselect(id GridWrapItemID) {
if len(l.selected) == 0 || l.selected[0] != id {
return
}
l.selected = nil
l.Refresh()
if f := l.OnUnselected; f != nil {
f(id)
}
}
// UnselectAll removes all items from the selection.
func (l *GridWrap) UnselectAll() {
if len(l.selected) == 0 {
return
}
selected := l.selected
l.selected = nil
l.Refresh()
if f := l.OnUnselected; f != nil {
for _, id := range selected {
f(id)
}
}
}
func (l *GridWrap) contentMinSize() fyne.Size {
padding := l.Theme().Size(theme.SizeNamePadding)
if l.Length == nil {
return fyne.NewSize(0, 0)
}
cols := l.ColumnCount()
rows := float32(math.Ceil(float64(l.Length()) / float64(cols)))
return fyne.NewSize(l.itemMin.Width, (l.itemMin.Height+padding)*rows-padding)
}
// Declare conformity with WidgetRenderer interface.
var _ fyne.WidgetRenderer = (*gridWrapRenderer)(nil)
type gridWrapRenderer struct {
objects []fyne.CanvasObject
list *GridWrap
scroller *widget.Scroll
layout *fyne.Container
}
func newGridWrapRenderer(objects []fyne.CanvasObject, l *GridWrap, scroller *widget.Scroll, layout *fyne.Container) *gridWrapRenderer {
lr := &gridWrapRenderer{objects: objects, list: l, scroller: scroller, layout: layout}
lr.scroller.OnScrolled = l.offsetUpdated
return lr
}
func (l *gridWrapRenderer) Layout(size fyne.Size) {
l.scroller.Resize(size)
}
func (l *gridWrapRenderer) MinSize() fyne.Size {
return l.scroller.MinSize().Max(l.list.itemMin)
}
func (l *gridWrapRenderer) Refresh() {
if f := l.list.CreateItem; f != nil {
item := createItemAndApplyThemeScope(f, l.list)
l.list.itemMin = item.MinSize()
}
l.Layout(l.list.Size())
l.scroller.Refresh()
l.layout.Layout.(*gridWrapLayout).updateGrid(false)
canvas.Refresh(l.list)
}
func (l *gridWrapRenderer) Destroy() {
}
func (l *gridWrapRenderer) Objects() []fyne.CanvasObject {
return l.objects
}
// Declare conformity with interfaces.
var (
_ fyne.Widget = (*gridWrapItem)(nil)
_ fyne.Tappable = (*gridWrapItem)(nil)
_ desktop.Hoverable = (*gridWrapItem)(nil)
)
type gridWrapItem struct {
BaseWidget
onTapped func()
background *canvas.Rectangle
child fyne.CanvasObject
hovered, selected bool
}
func newGridWrapItem(child fyne.CanvasObject, tapped func()) *gridWrapItem {
gw := &gridWrapItem{
child: child,
onTapped: tapped,
}
gw.ExtendBaseWidget(gw)
return gw
}
// CreateRenderer is a private method to Fyne which links this widget to its renderer.
func (gw *gridWrapItem) CreateRenderer() fyne.WidgetRenderer {
gw.ExtendBaseWidget(gw)
th := gw.Theme()
v := fyne.CurrentApp().Settings().ThemeVariant()
gw.background = canvas.NewRectangle(th.Color(theme.ColorNameHover, v))
gw.background.CornerRadius = th.Size(theme.SizeNameSelectionRadius)
gw.background.Hide()
objects := []fyne.CanvasObject{gw.background, gw.child}
return &gridWrapItemRenderer{widget.NewBaseRenderer(objects), gw}
}
// MinSize returns the size that this widget should not shrink below.
func (gw *gridWrapItem) MinSize() fyne.Size {
gw.ExtendBaseWidget(gw)
return gw.BaseWidget.MinSize()
}
// MouseIn is called when a desktop pointer enters the widget.
func (gw *gridWrapItem) MouseIn(*desktop.MouseEvent) {
gw.hovered = true
gw.Refresh()
}
// MouseMoved is called when a desktop pointer hovers over the widget.
func (gw *gridWrapItem) MouseMoved(*desktop.MouseEvent) {
}
// MouseOut is called when a desktop pointer exits the widget.
func (gw *gridWrapItem) MouseOut() {
gw.hovered = false
gw.Refresh()
}
// Tapped is called when a pointer tapped event is captured and triggers any tap handler.
func (gw *gridWrapItem) Tapped(*fyne.PointEvent) {
if gw.onTapped != nil {
gw.selected = true
gw.Refresh()
gw.onTapped()
}
}
// Declare conformity with the WidgetRenderer interface.
var _ fyne.WidgetRenderer = (*gridWrapItemRenderer)(nil)
type gridWrapItemRenderer struct {
widget.BaseRenderer
item *gridWrapItem
}
// MinSize calculates the minimum size of a listItem.
// This is based on the size of the status indicator and the size of the child object.
func (gw *gridWrapItemRenderer) MinSize() fyne.Size {
return gw.item.child.MinSize()
}
// Layout the components of the listItem widget.
func (gw *gridWrapItemRenderer) Layout(size fyne.Size) {
gw.item.background.Resize(size)
gw.item.child.Resize(size)
}
func (gw *gridWrapItemRenderer) Refresh() {
th := gw.item.Theme()
v := fyne.CurrentApp().Settings().ThemeVariant()
gw.item.background.CornerRadius = th.Size(theme.SizeNameSelectionRadius)
if gw.item.selected {
gw.item.background.FillColor = th.Color(theme.ColorNameSelection, v)
gw.item.background.Show()
} else if gw.item.hovered {
gw.item.background.FillColor = th.Color(theme.ColorNameHover, v)
gw.item.background.Show()
} else {
gw.item.background.Hide()
}
gw.item.background.Refresh()
canvas.Refresh(gw.item.super())
}
// Declare conformity with Layout interface.
var _ fyne.Layout = (*gridWrapLayout)(nil)
type gridItemAndID struct {
item *gridWrapItem
id GridWrapItemID
}
type gridWrapLayout struct {
gw *GridWrap
itemPool async.Pool[fyne.CanvasObject]
visible []gridItemAndID
wasVisible []gridItemAndID
}
func newGridWrapLayout(gw *GridWrap) fyne.Layout {
gwl := &gridWrapLayout{gw: gw}
gw.offsetUpdated = gwl.offsetUpdated
return gwl
}
func (l *gridWrapLayout) Layout(_ []fyne.CanvasObject, _ fyne.Size) {
l.updateGrid(true)
}
func (l *gridWrapLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
return l.gw.contentMinSize()
}
func (l *gridWrapLayout) getItem() *gridWrapItem {
item := l.itemPool.Get()
if item == nil {
if f := l.gw.CreateItem; f != nil {
child := createItemAndApplyThemeScope(f, l.gw)
item = newGridWrapItem(child, nil)
}
}
return item.(*gridWrapItem)
}
func (l *gridWrapLayout) offsetUpdated(pos fyne.Position) {
if l.gw.offsetY == pos.Y {
return
}
l.gw.offsetY = pos.Y
l.updateGrid(true)
}
func (l *gridWrapLayout) setupGridItem(li *gridWrapItem, id GridWrapItemID, focus bool) {
previousIndicator := li.selected
li.selected = false
for _, s := range l.gw.selected {
if id == s {
li.selected = true
break
}
}
if focus {
li.hovered = true
li.Refresh()
} else if previousIndicator != li.selected || li.hovered {
li.hovered = false
li.Refresh()
}
if f := l.gw.UpdateItem; f != nil {
f(id, li.child)
}
li.onTapped = func() {
if !fyne.CurrentDevice().IsMobile() {
l.gw.RefreshItem(l.gw.currentFocus)
canvas := fyne.CurrentApp().Driver().CanvasForObject(l.gw)
if canvas != nil {
canvas.Focus(l.gw.impl.(fyne.Focusable))
}
l.gw.currentFocus = id
}
l.gw.Select(id)
}
}
// ColumnCount returns the number of columns that are/will be shown
// in this GridWrap, based on the widget's current width.
//
// Since: 2.5
func (l *GridWrap) ColumnCount() int {
if l.colCountCache < 1 {
padding := l.Theme().Size(theme.SizeNamePadding)
l.colCountCache = 1
width := l.Size().Width
if width > l.itemMin.Width {
l.colCountCache = int(math.Floor(float64(width+padding) / float64(l.itemMin.Width+padding)))
}
}
return l.colCountCache
}
func (l *gridWrapLayout) updateGrid(newOnly bool) {
// code here is a mashup of listLayout.updateList and gridWrapLayout.Layout
padding := l.gw.Theme().Size(theme.SizeNamePadding)
length := 0
if f := l.gw.Length; f != nil {
length = f()
}
colCount := l.gw.ColumnCount()
visibleRowsCount := int(math.Ceil(float64(l.gw.scroller.Size().Height)/float64(l.gw.itemMin.Height+padding))) + 1
offY := l.gw.offsetY - float32(math.Mod(float64(l.gw.offsetY), float64(l.gw.itemMin.Height+padding)))
minRow := int(offY / (l.gw.itemMin.Height + padding))
minItem := minRow * colCount
maxRow := int(math.Min(float64(minRow+visibleRowsCount), math.Ceil(float64(length)/float64(colCount))))
maxItem := GridWrapItemID(math.Min(float64(maxRow*colCount), float64(length-1)))
if l.gw.UpdateItem == nil {
fyne.LogError("Missing UpdateCell callback required for GridWrap", nil)
}
// l.wasVisible now represents the currently visible items, while
// l.visible will be updated to represent what is visible *after* the update
l.wasVisible = append(l.wasVisible, l.visible...)
l.visible = l.visible[:0]
c := l.gw.scroller.Content.(*fyne.Container)
oldObjLen := len(c.Objects)
c.Objects = c.Objects[:0]
y := offY
curItemID := minItem
for row := minRow; row <= maxRow && curItemID <= maxItem; row++ {
x := float32(0)
for col := 0; col < colCount && curItemID <= maxItem; col++ {
item, ok := l.searchVisible(l.wasVisible, curItemID)
if !ok {
item = l.getItem()
if item == nil {
continue
}
item.Resize(l.gw.itemMin)
}
item.Move(fyne.NewPos(x, y))
item.Resize(l.gw.itemMin)
x += l.gw.itemMin.Width + padding
l.visible = append(l.visible, gridItemAndID{item: item, id: curItemID})
c.Objects = append(c.Objects, item)
curItemID++
}
y += l.gw.itemMin.Height + padding
}
l.nilOldSliceData(c.Objects, len(c.Objects), oldObjLen)
for _, old := range l.wasVisible {
if _, ok := l.searchVisible(l.visible, old.id); !ok {
l.itemPool.Put(old.item)
}
}
if newOnly {
for _, obj := range l.visible {
if _, ok := l.searchVisible(l.wasVisible, obj.id); !ok {
l.setupGridItem(obj.item, obj.id, l.gw.focused && l.gw.currentFocus == obj.id)
}
}
} else {
for _, obj := range l.visible {
l.setupGridItem(obj.item, obj.id, l.gw.focused && l.gw.currentFocus == obj.id)
}
}
// we don't need wasVisible now until next call to update
// nil out all references before truncating slice
for i := 0; i < len(l.wasVisible); i++ {
l.wasVisible[i].item = nil
}
l.wasVisible = l.wasVisible[:0]
}
// invariant: visible is in ascending order of IDs
func (l *gridWrapLayout) searchVisible(visible []gridItemAndID, id GridWrapItemID) (*gridWrapItem, bool) {
ln := len(visible)
idx := sort.Search(ln, func(i int) bool { return visible[i].id >= id })
if idx < ln && visible[idx].id == id {
return visible[idx].item, true
}
return nil, false
}
func (l *gridWrapLayout) nilOldSliceData(objs []fyne.CanvasObject, len, oldLen int) {
if oldLen > len {
objs = objs[:oldLen] // gain view into old data
for i := len; i < oldLen; i++ {
objs[i] = nil
}
}
}