package mujica import ( "log/slog" "slices" "strings" "github.com/egoist/mygo/ui" "mujicaui/internal" "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) } // DataTableView is a built DataTable and what happened to it in this frame. type DataTableView struct { *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 the retry action of a failed load. 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 := Tokens(c) d := Density(c) before := s.Sort var v DataTableView v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2) v.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 = msg(c, "data_table.noMatch") } v.retry = dataPlaceholder(c, opts.Status, opts.Error, empty, msg(c, "list.empty")) return } vis, cols := dataTableColumns(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. restore := dataSelectTheme(c, d.ControlHeight()/8) 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 } func dataTableColumns[R any, K comparable](c *ui.Context, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) { w := Density(c).ControlHeight() 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 := 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(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 }