Data: review r1 fixes
No files matched your search
@@ -41,7 +41,7 @@ func Card(c *ui.Context, opts CardOptions, body func()) *ui.Element {
|
||||
}
|
||||
card.Children(func() {
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).LineHeight(1.3)
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).LineHeight(1.3)
|
||||
if opts.Decorated {
|
||||
ui.Box(c).Height(7).FillWidth().Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cy := r.Y + r.H/2
|
||||
|
||||
@@ -58,10 +58,13 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func
|
||||
}
|
||||
wrap := opts.Loop || opts.Autoplay
|
||||
goTo := func(i int) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
if wrap {
|
||||
i = (i%n + n) % n
|
||||
}
|
||||
if n == 0 || i < 0 || i >= n || i == *index {
|
||||
if i < 0 || i >= n || i == *index {
|
||||
return
|
||||
}
|
||||
*index = i
|
||||
|
||||
@@ -124,9 +124,25 @@ func TestCarouselSnapshot(t *testing.T) {
|
||||
ui.Column(c).Padding(16).Size(440, 240).Children(func() {
|
||||
Carousel(c, *i, 4, CarouselOptions{Autoplay: true, Label: "Windows"}, func(n int) {
|
||||
ui.Column(c).Grow(1).Center().Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(18)
|
||||
ui.Text(c, fmt.Sprintf("Rose window %d", n+1)).FontSize(FontSize(c, 18))
|
||||
})
|
||||
}).Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarouselEmptyAutoplayKeys(t *testing.T) {
|
||||
index := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Carousel(c, &index, 0, CarouselOptions{Autoplay: true}, func(int) {}).Size(300, 200)
|
||||
}, 320, 240)
|
||||
for range 4 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
}
|
||||
if index != 0 {
|
||||
t.Fatalf("index %d", index)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ func collapsiblePanel(c *ui.Context, open bool, prog float32, fn func()) *ui.Ele
|
||||
if !open && prog == 0 {
|
||||
return nil
|
||||
}
|
||||
clip := ui.Column(c).Shrink(0)
|
||||
// Closing content takes no input or focus while it shrinks away.
|
||||
clip := ui.Column(c).Shrink(0).Disabled(!open)
|
||||
var inner *ui.Element
|
||||
clip.Children(func() {
|
||||
inner = ui.Column(c).Shrink(0)
|
||||
|
||||
@@ -84,3 +84,25 @@ func TestCollapsibleSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollapsibleClosingContentLeavesTabOrder(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
Collapsible(c, &open, CollapsibleOptions{Title: "Details"}, func() {
|
||||
Button(c, "Inner", ButtonOptions{})
|
||||
})
|
||||
Button(c, "After", ButtonOptions{})
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace) // closes; the content shrinks for 120 ms
|
||||
if open || !tt.HasText("Inner") {
|
||||
t.Fatalf("expected a closing animation: open %v", open)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("After") {
|
||||
t.Fatal("Tab entered closing content")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
@@ -62,6 +61,7 @@ type DataTableState[K comparable] struct {
|
||||
started bool
|
||||
sel K
|
||||
has bool
|
||||
toggled bool
|
||||
cache dataTableCache[K]
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ 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 that changed State.Sort.
|
||||
// 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.
|
||||
@@ -109,19 +109,25 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
dataTableRefresh(s, rows, opts)
|
||||
if s.has {
|
||||
if i, ok := s.cache.at[s.sel]; ok {
|
||||
s.cursor = i
|
||||
} else {
|
||||
s.cursor = -1
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
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
|
||||
@@ -139,12 +145,9 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
if opts.Multiple {
|
||||
s.rows.Selection = &s.Selection
|
||||
}
|
||||
before := s.Sort
|
||||
// Rows reach the data-row height of the density.
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Spacing = d.ControlHeight() / 8
|
||||
c.SetTheme(&t)
|
||||
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 {
|
||||
@@ -158,18 +161,19 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
ui.Text(c, dc.Text(r)).SingleLine()
|
||||
}
|
||||
})
|
||||
c.SetTheme(saved)
|
||||
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.sorted = s.Sort != before
|
||||
v.changed = e.Changed() && !v.sorted
|
||||
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)
|
||||
}
|
||||
@@ -252,6 +256,15 @@ func dataTablePrune[R any, K comparable](s *DataTableState[K], rows []R, opts Da
|
||||
}
|
||||
}
|
||||
|
||||
// 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}}
|
||||
@@ -275,27 +288,24 @@ func dataTableMark[R any, K comparable](c *ui.Context, s *DataTableState[K], opt
|
||||
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.OnAccent)
|
||||
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)
|
||||
box := ui.Box(c).Size(16, 16).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleCheckBox).Label(msg(c, "data_table.selectRow"))
|
||||
if on {
|
||||
box.Border(theme.BorderWidth, k.OnAccent).TextColor(k.OnAccent).Children(func() {
|
||||
ui.Icon(c, icons.Must("check")).Size(14, 14)
|
||||
}).Center()
|
||||
} else {
|
||||
box.Border(theme.BorderWidth, k.ControlBorder)
|
||||
}
|
||||
if box.Clicked() {
|
||||
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.Remove(key)
|
||||
} else {
|
||||
s.Selection.Add(key)
|
||||
} else {
|
||||
s.Selection.Remove(key)
|
||||
}
|
||||
slog.Debug("mujica: data_table toggle", "on", !on)
|
||||
s.toggled = true
|
||||
slog.Debug("mujica: data_table toggle", "on", on)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -10,58 +13,156 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// DataTableColumnPicker is a Columns button whose popover shows and hides columns.
|
||||
// The last visible column cannot be hidden.
|
||||
func DataTableColumnPicker[R any, K comparable](c *ui.Context, s *DataTableState[K], cols []DataColumn[R]) *ui.Element {
|
||||
// tableColumn is what the column menu knows of a column.
|
||||
type tableColumn struct {
|
||||
ID, Title string
|
||||
Width, MinWidth float32
|
||||
Sortable bool
|
||||
}
|
||||
|
||||
// tableWidthStep is how far one Narrower or Wider press resizes a column.
|
||||
const tableWidthStep float32 = 16
|
||||
|
||||
// tableColumnMenu is the keyboard path to sort, move, resize and, with hidden, show columns.
|
||||
func tableColumnMenu(c *ui.Context, cols []tableColumn, sort *ui.SortOrder, layout *ui.TableLayout, hidden *map[string]bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("eye")})
|
||||
b := Button(c, msg(c, "data_table.columns"), ButtonOptions{Variant: Secondary, Icon: icons.Must("settings")})
|
||||
open := ui.Local(b, "open", func() bool { return false })
|
||||
if b.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
visible := 0
|
||||
for _, col := range cols {
|
||||
if !s.Hidden[col.ID] {
|
||||
visible++
|
||||
order := tableOrder(cols, *layout)
|
||||
visible := len(cols)
|
||||
if hidden != nil {
|
||||
for _, col := range cols {
|
||||
if (*hidden)[col.ID] {
|
||||
visible--
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
|
||||
panel.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.PopupRadius).
|
||||
Padding(u * 2).Gap(u).MinWidth(200).MarginY(u)
|
||||
for _, col := range cols {
|
||||
ui.Column(c).Key(col.ID).Children(func() { dataTablePickerItem(c, s, col.ID, col.Title, visible) })
|
||||
Padding(u * 2).Gap(u).MinWidth(240).MarginY(u)
|
||||
for at, id := range order {
|
||||
col := cols[slices.IndexFunc(cols, func(col tableColumn) bool { return col.ID == id })]
|
||||
ui.Row(c).Key(id).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
if hidden != nil {
|
||||
tableVisibility(c, col, hidden, visible)
|
||||
} else {
|
||||
ui.Text(c, col.Title).SingleLine().Grow(1).Padding(0, u*2)
|
||||
}
|
||||
tableColumnActions(c, col, at, order, sort, layout)
|
||||
})
|
||||
}
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// dataTablePickerItem is one column's check box in the picker.
|
||||
func dataTablePickerItem[K comparable](c *ui.Context, s *DataTableState[K], id, title string, visible int) {
|
||||
// tableColumnActions are one column's sort, move and width buttons.
|
||||
func tableColumnActions(c *ui.Context, col tableColumn, at int, order []string, sort *ui.SortOrder, layout *ui.TableLayout) {
|
||||
act := func(icon, key string, disabled bool) bool {
|
||||
return Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must(icon), Label: fmt.Sprintf(msg(c, key), col.Title), Disabled: disabled}).Clicked()
|
||||
}
|
||||
if col.Sortable {
|
||||
for _, desc := range []bool{false, true} {
|
||||
icon, key := "arrow-up", "data_table.sortAsc"
|
||||
if desc {
|
||||
icon, key = "arrow-down", "data_table.sortDesc"
|
||||
}
|
||||
if act(icon, key, *sort == ui.SortOrder{Column: col.ID, Descending: desc}) {
|
||||
*sort = ui.SortOrder{Column: col.ID, Descending: desc}
|
||||
slog.Debug("mujica: table sort", "column", col.ID, "descending", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, step := range []int{-1, 1} {
|
||||
icon, key := "chevron-left", "data_table.moveLeft"
|
||||
if step > 0 {
|
||||
icon, key = "chevron-right", "data_table.moveRight"
|
||||
}
|
||||
to := at + step
|
||||
if act(icon, key, to < 0 || to >= len(order)) {
|
||||
ids := slices.Clone(order)
|
||||
ids[at], ids[to] = ids[to], ids[at]
|
||||
layout.Order = ids
|
||||
slog.Debug("mujica: table move", "column", col.ID, "to", to)
|
||||
}
|
||||
}
|
||||
w := tableWidth(col, *layout)
|
||||
least := max(col.MinWidth, tableWidthStep)
|
||||
if act("minus", "data_table.narrower", w <= least) {
|
||||
tableSetWidth(layout, col.ID, max(least, w-tableWidthStep))
|
||||
}
|
||||
if act("plus", "data_table.wider", false) {
|
||||
tableSetWidth(layout, col.ID, w+tableWidthStep)
|
||||
}
|
||||
}
|
||||
|
||||
// tableVisibility is a column's show/hide check box; the last visible one cannot hide.
|
||||
func tableVisibility(c *ui.Context, col tableColumn, hidden *map[string]bool, visible int) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
on := !s.Hidden[id]
|
||||
on := !(*hidden)[col.ID]
|
||||
last := on && visible == 1
|
||||
cb := ui.CheckboxBase(c, &on).Disabled(last).MinHeight(d.ControlHeight()).
|
||||
cb := ui.CheckboxBase(c, &on).Label(col.Title).Disabled(last).MinHeight(d.ControlHeight()).Grow(1).
|
||||
Padding(0, u*2).Gap(u * 2).AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
if cb.Hovered() && !last {
|
||||
cb.Background(k.SurfaceHover)
|
||||
}
|
||||
if cb.Changed() && !last {
|
||||
if s.Hidden == nil {
|
||||
s.Hidden = map[string]bool{}
|
||||
m := maps.Clone(*hidden)
|
||||
if m == nil {
|
||||
m = map[string]bool{}
|
||||
}
|
||||
s.Hidden[id] = !on
|
||||
slog.Debug("mujica: data_table column", "id", id, "visible", on)
|
||||
m[col.ID] = !on
|
||||
*hidden = m
|
||||
slog.Debug("mujica: table column", "id", col.ID, "visible", on)
|
||||
}
|
||||
cb.Children(func() {
|
||||
dataTableCheckBox(c, on)
|
||||
ui.Text(c, title).SingleLine()
|
||||
ui.Text(c, col.Title).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// tableOrder lists column IDs as the layout orders them, the rest after.
|
||||
func tableOrder(cols []tableColumn, l ui.TableLayout) []string {
|
||||
var ids []string
|
||||
for _, id := range l.Order {
|
||||
if slices.ContainsFunc(cols, func(col tableColumn) bool { return col.ID == id }) && !slices.Contains(ids, id) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
if !slices.Contains(ids, col.ID) {
|
||||
ids = append(ids, col.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// tableWidth is a column's set width; a sharing column starts from its least, at least 120.
|
||||
func tableWidth(col tableColumn, l ui.TableLayout) float32 {
|
||||
if w, ok := l.Widths[col.ID]; ok {
|
||||
return w
|
||||
}
|
||||
if col.Width > 0 {
|
||||
return col.Width
|
||||
}
|
||||
return max(col.MinWidth, 120)
|
||||
}
|
||||
|
||||
func tableSetWidth(l *ui.TableLayout, id string, w float32) {
|
||||
m := maps.Clone(l.Widths)
|
||||
if m == nil {
|
||||
m = map[string]float32{}
|
||||
}
|
||||
m[id] = w
|
||||
l.Widths = m
|
||||
slog.Debug("mujica: table width", "column", id, "width", w)
|
||||
}
|
||||
|
||||
// dataTableCheckBox draws a 16 DIP check box: OnAccent check on Accent when on.
|
||||
func dataTableCheckBox(c *ui.Context, on bool) {
|
||||
k := Tokens(c)
|
||||
|
||||
@@ -3,10 +3,13 @@ package mujica
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type relic struct {
|
||||
@@ -22,7 +25,6 @@ type tableHarness struct {
|
||||
compares int
|
||||
changes int
|
||||
sorts int
|
||||
picker bool
|
||||
c *ui.Context
|
||||
seen map[string]int
|
||||
}
|
||||
@@ -45,9 +47,6 @@ func (h *tableHarness) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
h.c, h.seen = c, map[string]int{}
|
||||
ui.Column(c).Padding(10).Gap(8).Children(func() {
|
||||
if h.picker {
|
||||
DataTableColumnPicker(c, &h.st, h.opts.Columns)
|
||||
}
|
||||
cols := h.opts
|
||||
for i := range cols.Columns {
|
||||
text := cols.Columns[i].Text
|
||||
@@ -154,8 +153,11 @@ func TestDataTableMultipleMarks(t *testing.T) {
|
||||
r, _ := tt.Find(name)
|
||||
tt.ClickAt(table.X-28, r.Y+r.H/2) // the mark column sits before Name
|
||||
}
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) {
|
||||
t.Fatalf("marks chose %d", h.st.Selection.Len())
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) || h.changes != 2 {
|
||||
t.Fatalf("marks chose %d, changes %d", h.st.Selection.Len(), h.changes)
|
||||
}
|
||||
if _, ok := tt.Find("Select row"); !ok {
|
||||
t.Fatal("row check box has no name")
|
||||
}
|
||||
_ = tt.Click("Age")
|
||||
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(4) {
|
||||
@@ -168,39 +170,52 @@ func TestDataTableMultipleMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableColumns(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
h.picker = true
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
h.st.Layout.Order = []string{"age", "name"}
|
||||
tt.Frame()
|
||||
age, _ := tt.Find("Age")
|
||||
name, _ := tt.Find("Name")
|
||||
if age.X >= name.X {
|
||||
t.Fatalf("order not applied: age %v name %v", age.X, name.X)
|
||||
}
|
||||
if err := tt.Click("Columns"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ages := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s == "Age" {
|
||||
ages++
|
||||
// tabTo presses Tab until the element labeled label has the focus.
|
||||
func tabTo(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
for range 60 {
|
||||
if tt.Focused(label) {
|
||||
return
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if ages != 2 { // header and picker
|
||||
t.Fatalf("picker missing: %q", tt.Texts())
|
||||
t.Fatalf("Tab never reached %q", label)
|
||||
}
|
||||
|
||||
func TestDataTableColumnMenuKeyboard(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 420, 480)
|
||||
tabTo(t, tt, "Columns")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Sort Age descending")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Sort != (ui.SortOrder{Column: "age", Descending: true}) || h.sorts != 1 {
|
||||
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tabTo(t, tt, "Move Age left")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if fmt.Sprint(h.st.Layout.Order) != "[age name]" {
|
||||
t.Fatalf("order %v", h.st.Layout.Order)
|
||||
}
|
||||
tabTo(t, tt, "Make Age wider")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Layout.Widths["age"] != 96 {
|
||||
t.Fatalf("widths %v", h.st.Layout.Widths)
|
||||
}
|
||||
tabTo(t, tt, "Name")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !h.st.Hidden["name"] || tt.HasText("Censer") {
|
||||
t.Fatalf("name not hidden: %v %q", h.st.Hidden, tt.Texts())
|
||||
if !h.st.Hidden["name"] {
|
||||
t.Fatalf("name not hidden: %v", h.st.Hidden)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tabTo(t, tt, "Age")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if h.st.Hidden["age"] {
|
||||
t.Fatal("hid the last visible column")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Censer") || !tt.HasText("Age") {
|
||||
t.Fatalf("table did not follow the menu: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCustomCellAndControlled(t *testing.T) {
|
||||
@@ -335,3 +350,16 @@ func TestDataTableSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataTableSelectionColors(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
if err := tt.Click("Bell"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Bell")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func DescriptionList(c *ui.Context, items []DescriptionItem, opts DescriptionLis
|
||||
cell.ColumnSpan(it.Span)
|
||||
}
|
||||
cell.Children(func() {
|
||||
ui.Text(c, it.Label).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, it.Label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Row(c).Gap(u).MinHeight(theme.MinHitSize).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, it.Value).Shrink(1).MinWidth(0)
|
||||
if !it.Copyable {
|
||||
|
||||
@@ -41,7 +41,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停(SurfaceHover)、选中(Accent 填充 + OnAccent 文字 + 勾选标记)、聚焦(列表外 2 DIP 焦点环)、网格选中(AccentText 边框 + 勾选)、加载、追加加载、失败、空。
|
||||
默认、悬停(SurfaceHover)、选中(Selection 背景 + Text 文字 + AccentText 勾选标记)、聚焦(列表外 2 DIP 焦点环)、网格选中(Selection 背景 + AccentText 边框与勾选)、加载、追加加载、失败、空。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -57,6 +57,6 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- 虚拟化由 MyGo `ui.List`/`ui.GridView` 提供:每帧构建的元素数由可见区域和预取范围决定;程序跳转(`ScrollTo`)时目标两侧各构建一屏。测试以 10 万条记录统计“最后一帧调用条目构建函数的次数”验证上限(list_test.go)。
|
||||
- MyGo 单选跟随键时只在原位置附近 1000 行内查找(ui/list.go:30 `listSearch`);插入或删除大量上方行后,单选可能被清除。
|
||||
- 单选按键保存:每帧先检查原位置的键,不符时扫描全部条目重新定位(MyGo 自身只在附近 1000 行内查找,ui/list.go:30),所以反转、排序或在网格前端插入后选择仍指向同一条目;条目被删除时选择清除。
|
||||
- Shift 连选的锚点由 MyGo 决定(ui/list.go `extend`),与平台习惯一致。
|
||||
- 网格模式没有 `ListState.Visible` 等滚动位置读取接口。
|
||||
@@ -34,7 +34,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 参数
|
||||
|
||||
`func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView`。另有 `DataTableColumnPicker(c, s, cols)`(“列”按钮与勾选弹层,控制显隐)和纯函数 `DataTableRows(rows, cols, sort, filter) []int`(本地排序筛选辅助,返回行下标)。
|
||||
`func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []R, opts DataTableOptions[R, K]) DataTableView`。表格上方有“列”按钮,弹层中每列提供显隐勾选框、升序/降序排序、左移/右移与减小/增大列宽(每次 16 DIP),这是排序、列顺序与列宽的键盘入口。另有纯函数 `DataTableRows(rows, cols, sort, filter) []int`(本地排序筛选辅助,返回行下标)。
|
||||
|
||||
`DataTableState` 字段:`Sort ui.SortOrder`、`Filter string`(不区分大小写包含匹配全部列的 `Text`)、`Hidden map[string]bool`(按列 ID 隐藏)、`Layout ui.TableLayout`(用户拖动得到的列顺序与列宽,可保存恢复)、`Selection ui.Selection[K]`(多选)。方法:`Selected()`、`Rows()`(筛选后行数)。
|
||||
|
||||
@@ -51,23 +51,25 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Accent 填充 + OnAccent 文字;单选首列菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
|
||||
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Selection 背景 + Text 文字;单选首列 AccentText 菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()`:选择变化;`Submitted()`:Enter 或双击;`SortChanged()`:点击表头改变了 `State.Sort`;`Retried()`:点击“重试”。
|
||||
`Changed()`:选择变化(含勾选框);`Submitted()`:Enter 或双击;`SortChanged()`:点击表头或列菜单改变了 `State.Sort`;`Retried()`:点击“重试”。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- ↑/↓、Home/End:移动选择;Enter:提交;输入字母按首个可见列定位。
|
||||
- 多选:Shift/Cmd(Ctrl) 配合点击或方向键;Cmd/Ctrl+A 全选。
|
||||
- 表头:点击排序、再次点击反向;拖动表头边缘调整列宽,双击适配内容;拖动表头移动列。
|
||||
- 列选择器:Tab 进入勾选项,Space 切换;Escape 关闭。
|
||||
- 列菜单:Tab 到“列”按钮,Enter 打开;Tab 在各列的勾选框与排序、移动、列宽按钮间移动,Enter/Space 执行;Escape 关闭。
|
||||
- 多选勾选框可用 Tab 到达,Space 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo `ui.Table` 只按调用方给出的顺序显示(docs/ui/table.md “Sorting”),因此本组件在本地完成排序,或在受控模式下交给调用方。
|
||||
- 表头内容只能是 `Title` 文字(ui/table.go `table` 构建表头),无法放置全选勾选框;全选使用 Cmd/Ctrl+A。
|
||||
- 表头内容只能是 `Title` 文字且只响应指针(ui/table.go `tableHeader`),因此全选使用 Cmd/Ctrl+A,键盘排序与调整列通过列菜单。
|
||||
- 共享列宽的列在列菜单中从 max(MinWidth, 120) DIP 开始调整。
|
||||
- 单选的跟随由组件按键重新定位,跨越任意距离都有效;多选的键盘锚点在大规模重排后由 MyGo 重置(ui/list.go:30)。
|
||||
- 删除的数据行对应的选择会在数据变化时清除;被筛选隐藏的行保留选择。
|
||||
- 10 万行 × 10 列:测试统计“最后一帧构建过单元格的行数”不超过 16,并断言重复帧不再调用比较函数(data_table_test.go)。
|
||||
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠(箭头旋转)、子项加载中、子项加载失败、空、首次加载与失败。
|
||||
默认、悬停、选中(Selection 背景 + AccentText 勾选标记)、聚焦、展开/折叠(箭头旋转)、子项加载中、子项加载失败、空、首次加载与失败。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -53,5 +53,5 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
- 基于 MyGo `ui.Outline` 而不是 `ui.Tree`:`ui.Tree` 会构建全部已展开节点(docs/ui/tree.md “A tree builds every item it shows”),不满足大量节点虚拟化要求。
|
||||
- `Outline` 每帧遍历已展开的节点以计算行(ui/outline.go `flatten`),元素数受可见区域限制,但遍历成本随展开节点数增长。
|
||||
- 单选跟随节点的搜索范围为 1000 行(ui/list.go:30)。
|
||||
- 单选按节点保存:行移动超过 MyGo 的 1000 行搜索范围(ui/list.go:30)时,组件扫描可见行重新定位并立即重建一帧;所选节点被折叠隐藏或删除时选择清除。
|
||||
- 异步加载由调用方在后台完成并通过 `Window.Update` 回到界面线程。
|
||||
@@ -29,7 +29,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 参数
|
||||
|
||||
`func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView`。`TreeTableState` 字段:`Sort`、`Layout`、`Selection`;方法:`Selected()`、`SetOpen`、`IsOpen`。
|
||||
`func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTableOptions[K]) DataTableView`。`TreeTableState` 字段:`Sort`、`Layout`、`Selection`;方法:`Selected()`、`SetOpen`、`IsOpen`。表格上方的“列”按钮弹层提供每列的排序、左移/右移与列宽调整,是这些操作的键盘入口。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -45,7 +45,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、选中(Accent 填充 + 勾选标记)、聚焦、展开/折叠、排序箭头、子项加载中/失败、空。
|
||||
默认、悬停、选中(Selection 背景 + AccentText 勾选标记)、聚焦、展开/折叠、排序箭头、子项加载中/失败、空。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -54,6 +54,7 @@ ui.Render(view, 400, 300, 1)
|
||||
## 键盘操作
|
||||
|
||||
- 与 Tree 相同的方向键与展开键;点击表头排序同级节点,再次点击反向;拖动表头边缘或表头调整列宽与顺序。
|
||||
- 列菜单:Tab 到“列”按钮,Enter 打开,Tab 到排序、移动与列宽按钮,Enter 执行,Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -44,4 +44,4 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- 基于 MyGo `ui.CollapsibleBase`;MyGo 的 `ui.Accordion` 外观固定且用内部状态移动焦点(ui/collapsible.go `accordionKeys`),因此键盘移动在组件内重新实现:向上移动时焦点在下一帧到达。
|
||||
- 关闭动画进行的 120 ms 内内容仍在界面树中。
|
||||
- 关闭动画进行的 120 ms 内内容仍在界面树中,但已禁用:不接收点击,也不进入 Tab 序列。
|
||||
@@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1)
|
||||
## 键盘操作
|
||||
|
||||
- Enter/Space:展开或关闭。
|
||||
- 关闭后内容不在 Tab 序列中。
|
||||
- 关闭(包括关闭动画期间)的内容不在 Tab 序列中。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ var dataPages = []page{
|
||||
// dataSection lays out a titled block of a demo.
|
||||
func dataSection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(12).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size))
|
||||
fn()
|
||||
})
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func dataStates(c *ui.Context, fn func(state func(name string, body func()))) {
|
||||
ui.Row(c).Gap(24).Wrap().AlignItems(ui.Start).Children(func() {
|
||||
fn(func(name string, body func()) {
|
||||
ui.Column(c).Gap(6).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, name).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
body()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,7 +134,6 @@ func dataTableDemo(c *ui.Context, log func(string)) {
|
||||
ui.Checkbox(c, &s.multiple, "Multiple")
|
||||
ui.Select(c, &s.status, dataStatuses)
|
||||
ui.TextInput(c, &s.table.Filter).Width(180).Placeholder("Filter")
|
||||
mujica.DataTableColumnPicker(c, &s.table, cols)
|
||||
})
|
||||
v := mujica.DataTable(c, &s.table, rows, mujica.DataTableOptions[dataRelic, int]{
|
||||
Columns: cols, Key: func(r dataRelic) int { return r.ID }, Multiple: s.multiple, Status: st,
|
||||
|
||||
@@ -63,7 +63,9 @@ func dataAccordionDemo(c *ui.Context, log func(string)) {
|
||||
{ID: "nave", Title: "Nave", Content: func() { ui.Text(c, "Pews of black oak, polished for the feast.") }},
|
||||
{ID: "crypt", Title: "Crypt (disabled)", Disabled: true, Content: func() {}},
|
||||
{ID: "bells", Title: "Bells", Content: func() {
|
||||
mujica.Button(c, "Ring", mujica.ButtonOptions{})
|
||||
if mujica.Button(c, "Ring", mujica.ButtonOptions{}).Clicked() {
|
||||
log("ring the bells")
|
||||
}
|
||||
}},
|
||||
}, mujica.AccordionOptions{Multiple: s.multiple, KeepOne: s.keepOne}).Width(420).Changed() {
|
||||
log(fmt.Sprintf("open %v", s.open))
|
||||
@@ -84,7 +86,9 @@ func dataCollapsibleDemo(c *ui.Context, log func(string)) {
|
||||
})
|
||||
if mujica.Collapsible(c, &s.open, mujica.CollapsibleOptions{Title: "Rites of the evening", Disabled: s.disabled}, func() {
|
||||
ui.Text(c, "Candles are lit at the seventh bell; the choir enters from the north door.")
|
||||
mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline})
|
||||
if mujica.Button(c, "Light candles", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() {
|
||||
log("light candles")
|
||||
}
|
||||
}).Changed() {
|
||||
log(fmt.Sprintf("open=%v", s.open))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
// dataDemo runs the demo of page id and returns its tester and event log.
|
||||
func dataDemo(t *testing.T, id string) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
i := slices.IndexFunc(dataPages, func(p page) bool { return p.ID == id })
|
||||
if i < 0 {
|
||||
t.Fatalf("no page %s", id)
|
||||
}
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Scroll(c).Fill().Padding(24).Children(func() {
|
||||
dataPages[i].Demo(c, func(s string) { *events = append(*events, s) })
|
||||
})
|
||||
}, 900, 700)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestDataDemoButtonsLog(t *testing.T) {
|
||||
tt, events := dataDemo(t, "074")
|
||||
for _, s := range []string{"Bells", "Ring"} {
|
||||
if err := tt.Click(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(*events, "ring the bells") {
|
||||
t.Fatalf("Ring not logged: %q", *events)
|
||||
}
|
||||
tt, events = dataDemo(t, "075")
|
||||
for _, s := range []string{"Open from outside", "Light candles"} {
|
||||
if err := tt.Click(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(*events, "light candles") {
|
||||
t.Fatalf("Light candles not logged: %q", *events)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ type ViewerImage struct {
|
||||
Alt string
|
||||
}
|
||||
|
||||
// ImageViewerView is a built ImageViewer and what happened to it in this frame.
|
||||
// Element is nil while the viewer is closed.
|
||||
// ImageViewerView is a built ImageViewer; Element is nil while closed.
|
||||
type ImageViewerView struct {
|
||||
*ui.Element
|
||||
changed bool
|
||||
|
||||
@@ -89,6 +89,7 @@ func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[
|
||||
if !s.started {
|
||||
s.started, s.cursor = true, -1
|
||||
}
|
||||
listLocate(s, n, opts.Key)
|
||||
k := Tokens(c)
|
||||
var v ListView
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
@@ -118,6 +119,21 @@ func List[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOptions[
|
||||
return v
|
||||
}
|
||||
|
||||
// listLocate finds the chosen key again when items moved or came and went.
|
||||
func listLocate[K comparable](s *ListState[K], n int, key func(int) K) {
|
||||
if !s.has || s.cursor >= 0 && s.cursor < n && key(s.cursor) == s.key {
|
||||
return
|
||||
}
|
||||
s.cursor = -1
|
||||
for i := range n {
|
||||
if key(i) == s.key {
|
||||
s.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
slog.Debug("mujica: list selection moved", "row", s.cursor)
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -128,11 +144,14 @@ func listRows[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
|
||||
} else {
|
||||
s.rows.Selection = nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
restore := dataSelectTheme(c, 0)
|
||||
defer restore()
|
||||
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)
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
}).Padding(d.Unit())
|
||||
@@ -155,16 +174,22 @@ func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
|
||||
} else {
|
||||
s.grid.Selection = nil
|
||||
}
|
||||
// Chosen items take AccentText borders, legible on both surfaces.
|
||||
// Chosen items take an AccentText ring over a Selection face.
|
||||
k := Tokens(c)
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Accent = Tokens(c).AccentText
|
||||
t.Accent = k.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() {
|
||||
chosen := listChosen(s, opts, i)
|
||||
face := ui.Column(c).Grow(1).Padding(d.Unit() * 2).Gap(d.Unit()).Radius(theme.ControlRadius)
|
||||
if chosen {
|
||||
face.Background(k.Selection)
|
||||
}
|
||||
face.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())
|
||||
if chosen {
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Absolute().Top(d.Unit()).Right(d.Unit()).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
}).Padding(d.Unit() * 2)
|
||||
@@ -172,6 +197,19 @@ func listGrid[K comparable](c *ui.Context, s *ListState[K], n int, opts ListOpti
|
||||
return e
|
||||
}
|
||||
|
||||
// dataSelectTheme shows MyGo's chosen rows as Selection with Text; spacing > 0 sets the unit.
|
||||
func dataSelectTheme(c *ui.Context, spacing float32) func() {
|
||||
k := Tokens(c)
|
||||
saved := c.Theme()
|
||||
t := *saved
|
||||
t.Accent, t.AccentText = k.Selection, k.Text
|
||||
if spacing > 0 {
|
||||
t.Spacing = spacing
|
||||
}
|
||||
c.SetTheme(&t)
|
||||
return func() { c.SetTheme(saved) }
|
||||
}
|
||||
|
||||
func listChosen[K comparable](s *ListState[K], opts ListOptions[K], i int) bool {
|
||||
if opts.Multiple {
|
||||
return s.Selection.Has(opts.Key(i))
|
||||
|
||||
@@ -2,9 +2,13 @@ package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type listHarness struct {
|
||||
@@ -213,3 +217,45 @@ func TestListSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestListKeyFollowsLargeReorder(t *testing.T) {
|
||||
h := newListHarness(5000)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Reverse(h.ids)
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); !ok || k != 3 || h.st.cursor != 4996 {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGridKeyFollowsInsert(t *testing.T) {
|
||||
h := newListHarness(12)
|
||||
h.opts.Grid = true
|
||||
h.opts.ItemWidth, h.opts.ItemHeight = 80, 60
|
||||
h.listSize = [2]float32{380, 300}
|
||||
tt := ui.NewTester(h.view, 420, 340)
|
||||
if err := tt.Click("Item 2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ids = append([]int{100}, h.ids...)
|
||||
tt.Frame()
|
||||
if k, _ := h.st.Selected(); k != 2 || h.st.cursor != 3 {
|
||||
t.Fatalf("after insert: selected %d at %d", k, h.st.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSelectionColors(t *testing.T) {
|
||||
h := newListHarness(5)
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Item 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Item 1")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ func init() {
|
||||
"data_table.noMatch": {"No matching rows", "没有匹配的行"},
|
||||
"data_table.selectRow": {"Select row", "选择行"},
|
||||
"data_table.columns": {"Columns", "列"},
|
||||
"data_table.sortAsc": {"Sort %s ascending", "按%s升序排序"},
|
||||
"data_table.sortDesc": {"Sort %s descending", "按%s降序排序"},
|
||||
"data_table.moveLeft": {"Move %s left", "将%s左移"},
|
||||
"data_table.moveRight": {"Move %s right", "将%s右移"},
|
||||
"data_table.narrower": {"Make %s narrower", "减小%s列宽"},
|
||||
"data_table.wider": {"Make %s wider", "增大%s列宽"},
|
||||
"timeline.done": {"Done", "已完成"},
|
||||
"timeline.active": {"Current", "进行中"},
|
||||
"timeline.warning": {"Warning", "警告"},
|
||||
|
||||
@@ -43,10 +43,10 @@ func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element
|
||||
e := ui.Column(c).Gap(u).Role(ui.RoleGroup)
|
||||
e.Children(func() {
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, opts.Title).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.End).Children(func() {
|
||||
ui.Text(c, num).Font(theme.SerifFont).FontSize(theme.H1Size).FontWeight(600).LineHeight(1.2).SingleLine()
|
||||
ui.Text(c, num).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).FontWeight(600).LineHeight(1.2).SingleLine()
|
||||
if opts.Unit != "" {
|
||||
ui.Text(c, opts.Unit).TextColor(k.TextMuted).Padding(0, 0, u, 0)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func Statistic(c *ui.Context, value float64, opts StatisticOptions) *ui.Element
|
||||
ui.Text(c, text).FontWeight(600).SingleLine()
|
||||
})
|
||||
if opts.ChangeNote != "" {
|
||||
ui.Text(c, opts.ChangeNote).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, opts.ChangeNote).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,3 +79,18 @@ func TestStatisticSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatisticTextScale(t *testing.T) {
|
||||
var h float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() { h = Statistic(c, 7, StatisticOptions{}).Bounds().H })
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
base := h
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame()
|
||||
if h < base*1.8 {
|
||||
t.Fatalf("height %v at 200%%, %v at 100%%", h, base)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -44,10 +44,10 @@ func Timeline(c *ui.Context, items []TimelineItem) *ui.Element {
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(u).Padding(0, 0, u*5, 0).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
if it.Time != "" {
|
||||
ui.Text(c, it.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, it.Time).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
if w := timelineWord(c, it.Status); w != "" {
|
||||
ui.Text(c, w).FontSize(theme.CaptionSize).TextColor(timelineColor(k, it.Status)).FontWeight(600)
|
||||
ui.Text(c, w).FontSize(FontSize(c, theme.CaptionSize)).TextColor(timelineColor(k, it.Status)).FontWeight(600)
|
||||
}
|
||||
})
|
||||
if it.Title != "" {
|
||||
|
||||
@@ -76,12 +76,34 @@ func (t *treeCore[K]) begin(label func(K) string, sel *ui.Selection[K], multiple
|
||||
}
|
||||
}
|
||||
|
||||
// end reads the chosen item after the outline was built.
|
||||
func (t *treeCore[K]) end() {
|
||||
t.has = t.cursor >= 0 && t.cursor < t.outline.Rows()
|
||||
if t.has {
|
||||
t.sel = t.outline.Item(t.cursor)
|
||||
// end reads a new choice, or finds the chosen item again after rows moved.
|
||||
func (t *treeCore[K]) end(c *ui.Context, changed bool) {
|
||||
rows := t.outline.Rows()
|
||||
at := t.cursor >= 0 && t.cursor < rows
|
||||
if changed || !t.has {
|
||||
t.has = at
|
||||
if at {
|
||||
t.sel = t.outline.Item(t.cursor)
|
||||
}
|
||||
return
|
||||
}
|
||||
if at && t.outline.Item(t.cursor) == t.sel {
|
||||
return
|
||||
}
|
||||
t.cursor = -1
|
||||
for i := range rows {
|
||||
if t.outline.Item(i) == t.sel {
|
||||
t.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if t.cursor < 0 {
|
||||
t.has = false
|
||||
return
|
||||
}
|
||||
// The rows showed the old place: build them again at the new one.
|
||||
c.Invalidate()
|
||||
slog.Debug("mujica: tree selection moved", "row", t.cursor)
|
||||
}
|
||||
|
||||
// chosen reports whether item shows as chosen.
|
||||
@@ -89,7 +111,7 @@ func (t *treeCore[K]) chosen(item K, sel *ui.Selection[K], multiple bool) bool {
|
||||
if multiple {
|
||||
return sel.Has(item)
|
||||
}
|
||||
return t.cursor >= 0 && t.cursor < t.outline.Rows() && t.outline.Item(t.cursor) == item
|
||||
return t.has && t.sel == item
|
||||
}
|
||||
|
||||
// itemStatus asks for unloaded children of an open item and shows loading or failure.
|
||||
@@ -118,7 +140,7 @@ func (t *treeCore[K]) itemStatus(c *ui.Context, item K, status func(K) DataStatu
|
||||
case DataFailed:
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(14, 14)
|
||||
ui.Text(c, msg(c, "list.failed")).FontSize(theme.CaptionSize).SingleLine()
|
||||
ui.Text(c, msg(c, "list.failed")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
if Button(c, msg(c, "list.retry"), ButtonOptions{Variant: Ghost}).MinHeight(theme.MinHitSize).Clicked() {
|
||||
slog.Debug("mujica: tree retry", "item", item)
|
||||
@@ -161,21 +183,23 @@ func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) Tre
|
||||
}
|
||||
// Outline rows pad by one unit above and below.
|
||||
h := d.ControlHeight() - 2*c.Theme().Space(1)
|
||||
restore := dataSelectTheme(c, 0)
|
||||
e := ui.Outline(c, &t.outline, opts.Roots, opts.Children, func(item K) {
|
||||
ui.Row(c).Grow(1).MinWidth(0).MinHeight(h).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, opts.Label(item)).SingleLine().Grow(1).MinWidth(0)
|
||||
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
|
||||
if t.chosen(item, &s.Selection, opts.Multiple) {
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
})
|
||||
restore()
|
||||
e.Grow(1).MinHeight(0).Padding(u).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)
|
||||
})
|
||||
t.end()
|
||||
t.end(c, v.changed)
|
||||
if v.changed {
|
||||
slog.Debug("mujica: tree changed", "row", t.cursor, "count", s.Selection.Len())
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ type TreeColumn[K comparable] struct {
|
||||
type TreeTableOptions[K comparable] struct {
|
||||
Columns []TreeColumn[K]
|
||||
Roots []K
|
||||
// Children returns an item's children: nil for a leaf, empty while unloaded; required.
|
||||
// Return a new slice when they change: sorted copies are cached by slice.
|
||||
// Children returns kids (nil leaf, empty unloaded); required; return a new slice on change.
|
||||
Children func(item K) []K
|
||||
// Label returns an item's text, read out and matched by typing; required.
|
||||
Label func(item K) string
|
||||
@@ -84,15 +83,15 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
if s.sorted == nil {
|
||||
s.sorted = map[K]treeSorted[K]{}
|
||||
}
|
||||
var cmp func(a, b K) int
|
||||
cols := make([]ui.TableColumn, len(opts.Columns))
|
||||
menu := make([]tableColumn, len(opts.Columns))
|
||||
for i, col := range opts.Columns {
|
||||
cols[i] = ui.TableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Align: col.Align, Sortable: col.Compare != nil}
|
||||
if col.ID == s.Sort.Column && col.Compare != nil {
|
||||
cmp = col.Compare
|
||||
}
|
||||
menu[i] = tableColumn{ID: col.ID, Title: col.Title, Width: col.Width, MinWidth: col.MinWidth, Sortable: col.Compare != nil}
|
||||
}
|
||||
order := s.Sort
|
||||
// The sort is read after the column menu, which may change it.
|
||||
var cmp func(a, b K) int
|
||||
var order ui.SortOrder
|
||||
sorted := func(src []K, cache *treeSorted[K]) []K {
|
||||
if cmp == nil || len(src) < 2 {
|
||||
return src
|
||||
@@ -120,20 +119,25 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
s.sorted[item] = cache
|
||||
return out
|
||||
}
|
||||
before := s.Sort
|
||||
var v DataTableView
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Children(func() {
|
||||
if len(opts.Roots) == 0 {
|
||||
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
ui.Row(c).Justify(ui.End).Children(func() { tableColumnMenu(c, menu, &s.Sort, &s.Layout, nil) })
|
||||
order = s.Sort
|
||||
for _, col := range opts.Columns {
|
||||
if col.ID == order.Column {
|
||||
cmp = col.Compare
|
||||
}
|
||||
}
|
||||
roots := sorted(opts.Roots, &s.roots)
|
||||
before := s.Sort
|
||||
head := s.Sort
|
||||
t.outline.List.Sort, t.outline.List.Columns = &s.Sort, s.Layout
|
||||
saved := c.Theme()
|
||||
th := *saved
|
||||
th.Spacing = d.ControlHeight() / 8
|
||||
c.SetTheme(&th)
|
||||
restore := dataSelectTheme(c, d.ControlHeight()/8)
|
||||
e := ui.OutlineTable(c, &t.outline, cols, roots, children, func(item K, col int) {
|
||||
if col > 0 {
|
||||
opts.Columns[col].Cell(item)
|
||||
@@ -143,20 +147,20 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
ui.Row(c).Grow(1).MinWidth(0).AlignItems(ui.Center).Children(func() { opts.Columns[0].Cell(item) })
|
||||
t.itemStatus(c, item, opts.ItemStatus, opts.Load)
|
||||
if t.chosen(item, &s.Selection, opts.Multiple) {
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0)
|
||||
ui.Icon(c, icons.Must("check")).Size(16, 16).Shrink(0).TextColor(k.AccentText)
|
||||
}
|
||||
})
|
||||
})
|
||||
c.SetTheme(saved)
|
||||
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 = t.outline.List.Columns
|
||||
v.sorted = s.Sort != before
|
||||
v.changed = e.Changed() && !v.sorted
|
||||
v.changed = e.Changed() && s.Sort == head
|
||||
v.submitted = e.Submitted()
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
})
|
||||
t.end()
|
||||
v.sorted = s.Sort != before
|
||||
t.end(c, v.changed)
|
||||
if v.sorted {
|
||||
slog.Debug("mujica: tree_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestTreeTableSnapshot(t *testing.T) {
|
||||
h.st.SetOpen("Vestry", true)
|
||||
h.st.Sort = ui.SortOrder{Column: "name"}
|
||||
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 2, map[string]bool{}
|
||||
h.st.tree.sel, h.st.tree.has = "Vestry", true
|
||||
return h
|
||||
})
|
||||
(*h).c = c
|
||||
@@ -190,3 +191,56 @@ func TestTreeTableSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTreeTableSelectionFollowsLargeSort(t *testing.T) {
|
||||
kids := make([]int, 3000)
|
||||
for i := range kids {
|
||||
kids[i] = i + 1
|
||||
}
|
||||
var st TreeTableState[int]
|
||||
view := func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TreeTable(c, &st, TreeTableOptions[int]{Roots: []int{0}, Label: strconv.Itoa,
|
||||
Children: func(n int) []int {
|
||||
if n == 0 {
|
||||
return kids
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Columns: []TreeColumn[int]{{ID: "n", Title: "N", Cell: func(n int) { ui.Text(c, "#"+strconv.Itoa(n)) }, Compare: cmp.Compare[int]}},
|
||||
}).Size(320, 360)
|
||||
}
|
||||
st.SetOpen(0, true)
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
if err := tt.Click("#3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Sort = ui.SortOrder{Column: "n", Descending: true}
|
||||
tt.Frame()
|
||||
if k, ok := st.Selected(); !ok || k != 3 || st.tree.outline.Item(st.tree.cursor) != 3 {
|
||||
t.Fatalf("after sort: selected %d %v at row %d", k, ok, st.tree.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTableColumnMenuKeyboard(t *testing.T) {
|
||||
h := newTTHarness()
|
||||
tt := ui.NewTester(h.view, 420, 480)
|
||||
tabTo(t, tt, "Columns")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Sort Size ascending")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.st.Sort != (ui.SortOrder{Column: "size"}) || h.sorts != 1 {
|
||||
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
|
||||
}
|
||||
tabTo(t, tt, "Move Name right")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tabTo(t, tt, "Make Name narrower")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if fmt.Sprint(h.st.Layout.Order) != "[size name]" || h.st.Layout.Widths["name"] != 104 {
|
||||
t.Fatalf("layout %+v", h.st.Layout)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if got := h.order(tt); got != "[Belfry Organ Vestry]" {
|
||||
t.Fatalf("rows not sorted by size: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
type treeHarness struct {
|
||||
@@ -185,8 +189,7 @@ func TestTreeLargeIsVirtualized(t *testing.T) {
|
||||
}}).Size(320, 360)
|
||||
}
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
// Label runs twice per built row (row name and text). A jump builds a
|
||||
// view on each side of its target: 2×10 rows of 36 DIPs, plus 2×2 overscan and 2 edges.
|
||||
// Two Label calls per row; a jump builds 2×(10 rows + 2 overscan) + 2 edges.
|
||||
const bound = 2 * 26
|
||||
if built == 0 || built > bound {
|
||||
t.Fatalf("collapsed: label calls %d, bound %d", built, bound)
|
||||
@@ -213,6 +216,7 @@ func TestTreeSnapshot(t *testing.T) {
|
||||
h.st.SetOpen("Crypt", true)
|
||||
h.status["Crypt"] = DataLoading
|
||||
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 1, map[string]bool{}
|
||||
h.st.tree.sel, h.st.tree.has = "Pulpit", true
|
||||
return h
|
||||
})
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
@@ -220,3 +224,31 @@ func TestTreeSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTreeSelectionFollowsLargeReorder(t *testing.T) {
|
||||
roots := make([]int, 3000)
|
||||
for i := range roots {
|
||||
roots[i] = i
|
||||
}
|
||||
var st TreeState[int]
|
||||
view := func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: func(int) []int { return nil },
|
||||
Label: func(n int) string { return fmt.Sprintf("Node %d", n) }}).Size(320, 360)
|
||||
}
|
||||
tt := ui.NewTester(view, 400, 400)
|
||||
if err := tt.Click("Node 2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := tt.Find("Node 2")
|
||||
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
|
||||
if want := toRGBA(theme.Light().Selection); got != want {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
roots = slices.Clone(roots)
|
||||
slices.Reverse(roots)
|
||||
tt.Frame()
|
||||
if k, ok := st.Selected(); !ok || k != 2 || st.tree.cursor != 2997 {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, st.tree.cursor)
|
||||
}
|
||||
}
|
||||