Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,354 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DataColumn is a column of a DataTable showing rows of type R.
|
||||
type DataColumn[R any] struct {
|
||||
// ID identifies the column in sort, layout and visibility; required, unique.
|
||||
ID string
|
||||
Title string
|
||||
// Width is in DIPs; 0 shares the room left. MinWidth bounds resizing.
|
||||
Width, MinWidth float32
|
||||
Align ui.Align
|
||||
// Sortable lets a header click sort by the column; local mode needs Compare.
|
||||
Sortable bool
|
||||
// Compare orders two rows, as cmp.Compare does.
|
||||
Compare func(a, b R) int
|
||||
// Text is the cell's plain text, for filtering and the default cell; required.
|
||||
Text func(r R) string
|
||||
// Cell builds a custom cell; nil shows Text.
|
||||
Cell func(r R)
|
||||
}
|
||||
|
||||
// DataTableOptions configure a DataTable.
|
||||
type DataTableOptions[R any, K comparable] struct {
|
||||
Columns []DataColumn[R]
|
||||
// Key returns a row's stable ID; required.
|
||||
Key func(r R) K
|
||||
Multiple bool
|
||||
// Controlled shows rows as given: the caller sorts and filters on SortChanged.
|
||||
Controlled bool
|
||||
// Version must change when rows change in place; a new slice is noticed.
|
||||
Version int
|
||||
// Empty replaces the built-in "No items" text.
|
||||
Empty string
|
||||
Status DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// DataTableState holds a DataTable's sort, filter, layout and selection.
|
||||
type DataTableState[K comparable] struct {
|
||||
Sort ui.SortOrder
|
||||
// Filter keeps rows whose cells contain it, ignoring case, in local mode.
|
||||
Filter string
|
||||
// Hidden names the columns not shown, by ID.
|
||||
Hidden map[string]bool
|
||||
// Layout is the column order and widths the user set.
|
||||
Layout ui.TableLayout
|
||||
// Selection holds the chosen row IDs of a Multiple table.
|
||||
Selection ui.Selection[K]
|
||||
rows ui.ListState
|
||||
cursor int
|
||||
started bool
|
||||
sel K
|
||||
has bool
|
||||
toggled bool
|
||||
cache dataTableCache[K]
|
||||
}
|
||||
|
||||
// dataTableCache is the sorted, filtered view, rebuilt only when its inputs change.
|
||||
type dataTableCache[K comparable] struct {
|
||||
valid bool
|
||||
first any
|
||||
n int
|
||||
version int
|
||||
sort ui.SortOrder
|
||||
filter string
|
||||
view []int
|
||||
at map[K]int
|
||||
}
|
||||
|
||||
// Selected returns the chosen row's ID, or the last chosen of a Multiple table.
|
||||
func (s *DataTableState[K]) Selected() (K, bool) { return s.sel, s.has }
|
||||
|
||||
// Rows returns how many rows the table shows after filtering.
|
||||
func (s *DataTableState[K]) Rows() int { return len(s.cache.view) }
|
||||
|
||||
// VisibleRows returns the range of view rows built this frame.
|
||||
func (s *DataTableState[K]) VisibleRows() (first, last int) { return s.rows.Visible() }
|
||||
|
||||
// DataTableView is a built DataTable and what happened to it in this frame.
|
||||
type DataTableView struct {
|
||||
Element *ui.Element
|
||||
changed, submitted, sorted, retry bool
|
||||
}
|
||||
|
||||
// Changed reports a new selection.
|
||||
func (v DataTableView) Changed() bool { return v.changed }
|
||||
|
||||
// Submitted reports Enter or a double click on a row.
|
||||
func (v DataTableView) Submitted() bool { return v.submitted }
|
||||
|
||||
// SortChanged reports a header click or menu choice that changed State.Sort.
|
||||
func (v DataTableView) SortChanged() bool { return v.sorted }
|
||||
|
||||
// Retried reports a click on a failed load's retry.
|
||||
func (v DataTableView) Retried() bool { return v.retry }
|
||||
|
||||
const dataTableMarkID = "mujica.select"
|
||||
|
||||
// DataTable shows rows in columns, building only those in view.
|
||||
func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView {
|
||||
dataTableCheck(opts)
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Element.Children(func() {
|
||||
if len(rows) > 0 {
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
tableColumnMenu(c, dataTableMenu(opts), &s.Sort, &s.Layout, &s.Hidden)
|
||||
})
|
||||
}
|
||||
dataTableRefresh(s, rows, opts)
|
||||
if s.has {
|
||||
if i, ok := s.cache.at[s.sel]; ok {
|
||||
s.cursor = i
|
||||
} else {
|
||||
s.cursor = -1
|
||||
}
|
||||
}
|
||||
view := s.cache.view
|
||||
if len(view) == 0 {
|
||||
empty := opts.Empty
|
||||
if len(rows) > 0 {
|
||||
empty = core.Msg(c, "data_table.noMatch")
|
||||
}
|
||||
v.retry = DataPlaceholder(c, opts.Status, opts.Error, empty, core.Msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
vis, cols := dataTableColumns(dataRowUnit(c), s, opts)
|
||||
s.rows.Key = func(i int) any { return opts.Key(rows[view[i]]) }
|
||||
s.rows.Label = func(i int) string { return opts.Columns[vis[0]].Text(rows[view[i]]) }
|
||||
s.rows.Selected, s.rows.Sort, s.rows.Columns = &s.cursor, &s.Sort, s.Layout
|
||||
s.rows.Selection = nil
|
||||
if opts.Multiple {
|
||||
s.rows.Selection = &s.Selection
|
||||
}
|
||||
// Rows reach the data-row height of the density.
|
||||
unit := dataRowUnit(c)
|
||||
restore := DataSelectTheme(c, unit)
|
||||
head := s.Sort
|
||||
e := ui.Table(c, &s.rows, cols, len(view), func(row, col int) {
|
||||
r := rows[view[row]]
|
||||
if col == 0 {
|
||||
dataTableMark(c, s, opts, opts.Key(r), row)
|
||||
return
|
||||
}
|
||||
dc := opts.Columns[vis[col-1]]
|
||||
if dc.Cell != nil {
|
||||
dc.Cell(r)
|
||||
} else {
|
||||
ui.Text(c, dc.Text(r)).SingleLine()
|
||||
}
|
||||
})
|
||||
restore()
|
||||
e.Grow(1).MinHeight(0).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius)
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
s.Layout = s.rows.Columns
|
||||
v.changed = e.Changed() && s.Sort == head || s.toggled
|
||||
s.toggled = false
|
||||
v.submitted = e.Submitted()
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
if s.cursor >= 0 && s.cursor < len(view) {
|
||||
s.sel, s.has = opts.Key(rows[view[s.cursor]]), true
|
||||
}
|
||||
})
|
||||
v.sorted = s.Sort != before
|
||||
if v.sorted {
|
||||
slog.Debug("mujica: data_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
|
||||
}
|
||||
if v.changed {
|
||||
slog.Debug("mujica: data_table select", "count", s.Selection.Len())
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func dataTableCheck[R any, K comparable](opts DataTableOptions[R, K]) {
|
||||
if opts.Key == nil || len(opts.Columns) == 0 {
|
||||
panic("mujica: DataTable needs Key and Columns")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, col := range opts.Columns {
|
||||
if col.ID == "" || seen[col.ID] || col.ID == dataTableMarkID || col.Text == nil {
|
||||
panic("mujica: DataTable column needs a unique ID and Text: " + col.ID)
|
||||
}
|
||||
if col.Sortable && !opts.Controlled && col.Compare == nil {
|
||||
panic("mujica: sortable DataTable column needs Compare: " + col.ID)
|
||||
}
|
||||
seen[col.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// dataTableRefresh rebuilds the view only when rows, sort or filter changed.
|
||||
func dataTableRefresh[R any, K comparable](s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) {
|
||||
cc := &s.cache
|
||||
var first any
|
||||
if len(rows) > 0 {
|
||||
first = &rows[0]
|
||||
}
|
||||
sort, filter := s.Sort, s.Filter
|
||||
if opts.Controlled {
|
||||
sort, filter = ui.SortOrder{}, ""
|
||||
}
|
||||
data := !cc.valid || cc.first != first || cc.n != len(rows) || cc.version != opts.Version
|
||||
if !data && cc.sort == sort && cc.filter == filter {
|
||||
return
|
||||
}
|
||||
var view []int
|
||||
if opts.Controlled {
|
||||
view = make([]int, len(rows))
|
||||
for i := range view {
|
||||
view[i] = i
|
||||
}
|
||||
} else {
|
||||
view = DataTableRows(rows, opts.Columns, sort, filter)
|
||||
}
|
||||
at := make(map[K]int, len(view))
|
||||
for i, j := range view {
|
||||
at[opts.Key(rows[j])] = i
|
||||
}
|
||||
if len(at) != len(view) {
|
||||
panic("mujica: DataTable rows with duplicate keys")
|
||||
}
|
||||
*cc = dataTableCache[K]{valid: true, first: first, n: len(rows), version: opts.Version, sort: sort, filter: filter, view: view, at: at}
|
||||
if data {
|
||||
dataTablePrune(s, rows, opts)
|
||||
}
|
||||
slog.Debug("mujica: data_table view", "rows", len(view), "of", len(rows))
|
||||
}
|
||||
|
||||
// dataTablePrune drops chosen IDs whose rows left the data.
|
||||
func dataTablePrune[R any, K comparable](s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) {
|
||||
all := s.cache.at
|
||||
if len(s.cache.view) != len(rows) {
|
||||
all = make(map[K]int, len(rows))
|
||||
for i, r := range rows {
|
||||
all[opts.Key(r)] = i
|
||||
}
|
||||
}
|
||||
for key := range s.Selection.All() {
|
||||
if _, ok := all[key]; !ok {
|
||||
s.Selection.Remove(key)
|
||||
}
|
||||
}
|
||||
if _, ok := all[s.sel]; s.has && !ok {
|
||||
s.has = false
|
||||
}
|
||||
}
|
||||
|
||||
// dataTableMenu lists the columns for the column menu.
|
||||
func dataTableMenu[R any, K comparable](opts DataTableOptions[R, K]) []tableColumn {
|
||||
cols := make([]tableColumn, len(opts.Columns))
|
||||
for i, col := range opts.Columns {
|
||||
cols[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Sortable}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// dataRowUnit is the table spacing unit: rows reach the density's height and fit scaled text.
|
||||
func dataRowUnit(c *ui.Context) float32 {
|
||||
// MyGo rows and the header are 8 units high with 1.5 units of padding above and below.
|
||||
return max(core.Density(c).ControlHeight()/8, core.FontSize(c, theme.BodySize)*theme.LineHeight/5)
|
||||
}
|
||||
|
||||
func dataTableColumns[R any, K comparable](unit float32, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
|
||||
// The check box keeps its full hit width inside MyGo's 2.5-unit cell padding.
|
||||
w := theme.MinHitSize + 5*unit
|
||||
cols := []ui.TableColumn{{ID: dataTableMarkID, Width: w, Fixed: true, Align: ui.Center}}
|
||||
var vis []int
|
||||
for i, col := range opts.Columns {
|
||||
if s.Hidden[col.ID] {
|
||||
continue
|
||||
}
|
||||
vis = append(vis, i)
|
||||
cols = append(cols, ui.TableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Align: col.Align, Sortable: col.Sortable})
|
||||
}
|
||||
if len(vis) == 0 {
|
||||
panic("mujica: DataTable with every column hidden")
|
||||
}
|
||||
return vis, cols
|
||||
}
|
||||
|
||||
// dataTableMark draws the selection column: a check box, or a diamond on the chosen row.
|
||||
func dataTableMark[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K], key K, row int) {
|
||||
k := core.Tokens(c)
|
||||
if !opts.Multiple {
|
||||
if row == s.cursor {
|
||||
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), r.W), k.AccentText)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
on := s.Selection.Has(key)
|
||||
cb := ui.CheckboxBase(c, &on).Label(core.Msg(c, "data_table.selectRow")).Size(theme.MinHitSize, theme.MinHitSize).
|
||||
Justify(ui.Center).AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
cb.Children(func() { dataTableCheckBox(c, on) })
|
||||
if cb.Changed() {
|
||||
if on {
|
||||
s.Selection.Add(key)
|
||||
} else {
|
||||
s.Selection.Remove(key)
|
||||
}
|
||||
s.toggled = true
|
||||
slog.Debug("mujica: data_table toggle", "on", on)
|
||||
}
|
||||
}
|
||||
|
||||
// DataTableRows sorts and filters rows as a local DataTable does; it returns indices.
|
||||
func DataTableRows[R any](rows []R, cols []DataColumn[R], sort ui.SortOrder, filter string) []int {
|
||||
view := make([]int, 0, len(rows))
|
||||
q := strings.ToLower(filter)
|
||||
for i, r := range rows {
|
||||
if q == "" || dataTableMatch(r, cols, q) {
|
||||
view = append(view, i)
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
if col.ID == sort.Column && col.Compare != nil {
|
||||
cmp := col.Compare
|
||||
slices.SortStableFunc(view, func(a, b int) int {
|
||||
if sort.Descending {
|
||||
return cmp(rows[b], rows[a])
|
||||
}
|
||||
return cmp(rows[a], rows[b])
|
||||
})
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func dataTableMatch[R any](r R, cols []DataColumn[R], q string) bool {
|
||||
for _, col := range cols {
|
||||
if strings.Contains(strings.ToLower(col.Text(r)), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user