Files
MujicaUI/list.go
T

236 lines
7.0 KiB
Go

package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// DataStatus is the load state of a data view's rows.
type DataStatus int
const (
DataReady DataStatus = iota
// DataLoading is the first load, before any row came.
DataLoading
// DataLoadingMore appends rows below those shown.
DataLoadingMore
// DataFailed shows the error with a retry action.
DataFailed
// DataUnloaded marks a tree item whose children were not asked for yet.
DataUnloaded
)
// ListOptions configure a List of items keyed by K.
type ListOptions[K comparable] struct {
// Key returns item i's stable identity; required.
Key func(i int) K
// Label returns item i's text, read out and matched by typing; required.
Label func(i int) string
Multiple bool
// Grid lays items out in columns at least ItemWidth wide, ItemHeight high.
Grid bool
ItemWidth, ItemHeight float32
// Empty replaces the built-in "No items" text.
Empty string
Status DataStatus
// Error describes a DataFailed status; empty uses the built-in text.
Error string
}
// ListState is a List's selection and place. Its zero value selects nothing.
type ListState[K comparable] struct {
// Selection holds the chosen keys of a Multiple list.
Selection ui.Selection[K]
rows ui.ListState
grid ui.GridState
cursor int
started bool
key K
has bool
}
// Selected returns the chosen item's key, or the last chosen of a Multiple list.
func (s *ListState[K]) Selected() (K, bool) { return s.key, s.has }
// ScrollTo brings item i to the top of the list.
func (s *ListState[K]) ScrollTo(i int) {
s.rows.ScrollTo(i, ui.Start)
s.grid.ScrollTo(i, ui.Start)
}
// ListView is a built List and what happened to it in this frame.
type ListView struct {
*ui.Element
changed, submitted, retry bool
}
// Changed reports a new selection.
func (v ListView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on an item.
func (v ListView) Submitted() bool { return v.submitted }
// Retried reports a click on the retry action of a failed load.
func (v ListView) Retried() bool { return v.retry }
// List shows n items, building only those in view, as rows or a grid.
func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) ListView {
if opts.Key == nil || opts.Label == nil {
panic("mujica: List needs Key and Label")
}
if n < 0 {
panic("mujica: List with negative item count")
}
if !s.started {
s.started, s.cursor = true, -1
}
k := Tokens(c)
var v ListView
v.Element = ui.Column(c).MinHeight(0)
v.Children(func() {
if n == 0 {
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
return
}
var e *ui.Element
if opts.Grid {
e = listGrid(c, s, n, opts, item)
} else {
e = listRows(c, s, n, opts, item)
}
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
internal.Ring(e, k.Focus, theme.ControlRadius)
v.changed, v.submitted = e.Changed(), e.Submitted()
v.retry = dataFooter(c, opts.Status, opts.Error)
})
s.has = s.cursor >= 0 && s.cursor < n
if s.has {
s.key = opts.Key(s.cursor)
}
if v.changed {
slog.Debug("mujica: list changed", "selected", s.cursor, "count", s.Selection.Len())
}
return v
}
func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
d := Density(c)
s.rows.Key = func(i int) any { return opts.Key(i) }
s.rows.Label = opts.Label
s.rows.Selected = &s.cursor
if opts.Multiple {
s.rows.Selection = &s.Selection
} else {
s.rows.Selection = nil
}
return ui.List(c, &s.rows, n, func(i int) {
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Gap(d.Unit() * 2).Children(func() { item(i) })
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
}
})
}).Padding(d.Unit())
}
func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[K], item func(i int)) *ui.Element {
d := Density(c)
w, h := opts.ItemWidth, opts.ItemHeight
if w <= 0 {
w = 160
}
if h <= 0 {
h = 120
}
s.grid.Key = func(i int) any { return opts.Key(i) }
s.grid.Label = opts.Label
s.grid.Selected = &s.cursor
if opts.Multiple {
s.grid.Selection = &s.Selection
} else {
s.grid.Selection = nil
}
// Chosen items take AccentText borders, legible on both surfaces.
saved := c.Theme()
t := *saved
t.Accent = Tokens(c).AccentText
c.SetTheme(&t)
e := ui.GridView(c, &s.grid, n, w, h, func(i int) {
ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Children(func() {
item(i)
if listChosen(s, opts, i) {
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit())
}
})
}).Padding(d.Unit() * 2)
c.SetTheme(saved)
return e
}
func listChosen[K comparable](s *ListState[K], opts ListOptions[K], i int) bool {
if opts.Multiple {
return s.Selection.Has(opts.Key(i))
}
return s.cursor == i
}
// dataPlaceholder fills an empty view with its status and reports a retry.
func dataPlaceholder(c *ui.Context, st DataStatus, errText, empty, emptyDefault string) bool {
k := Tokens(c)
u := Density(c).Unit()
retry := false
ui.Column(c).Grow(1).MinHeight(u * 30).Center().Gap(u * 3).Padding(u * 6).Children(func() {
switch st {
case DataLoading, DataLoadingMore, DataUnloaded:
Spinner(c, SpinnerOptions{Label: msg(c, "list.loading"), ShowLabel: true})
case DataFailed:
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(20, 20)
ui.Text(c, dataError(c, errText)).Role(ui.RoleStatus)
})
retry = Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked()
default:
if empty == "" {
empty = emptyDefault
}
ui.Icon(c, icons.Must("inbox")).Size(24, 24).TextColor(k.TextMuted)
ui.Text(c, empty).TextColor(k.TextMuted)
}
})
return retry
}
// dataFooter shows appending and failed loads below rows and reports a retry.
func dataFooter(c *ui.Context, st DataStatus, errText string) bool {
k := Tokens(c)
u := Density(c).Unit()
retry := false
switch st {
case DataLoadingMore:
ui.Row(c).Padding(u*2, u*3).Shrink(0).Children(func() {
Spinner(c, SpinnerOptions{Label: msg(c, "list.loadingMore"), ShowLabel: true})
})
case DataFailed:
ui.Row(c).Padding(u*2, u*3).Gap(u * 3).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.Danger).Grow(1).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
ui.Text(c, dataError(c, errText)).Role(ui.RoleStatus)
})
retry = Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw")}).Clicked()
})
}
return retry
}
func dataError(c *ui.Context, s string) string {
if s == "" {
return msg(c, "list.failed")
}
return s
}