diff --git a/card.go b/card.go index 6f06830..b246cd2 100644 --- a/card.go +++ b/card.go @@ -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 diff --git a/carousel.go b/carousel.go index 81078e3..05f4587 100644 --- a/carousel.go +++ b/carousel.go @@ -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 diff --git a/carousel_test.go b/carousel_test.go index b30e2e4..aae5119 100644 --- a/carousel_test.go +++ b/carousel_test.go @@ -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) + } +} diff --git a/collapsible.go b/collapsible.go index 7e90fa4..262e7d2 100644 --- a/collapsible.go +++ b/collapsible.go @@ -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) diff --git a/collapsible_test.go b/collapsible_test.go index 90e5b28..f6ecd90 100644 --- a/collapsible_test.go +++ b/collapsible_test.go @@ -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") + } +} diff --git a/data_table.go b/data_table.go index eafc719..86ca4d6 100644 --- a/data_table.go +++ b/data_table.go @@ -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) } } diff --git a/data_table_columns.go b/data_table_columns.go index 430b8e1..62494d8 100644 --- a/data_table_columns.go +++ b/data_table_columns.go @@ -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) diff --git a/data_table_test.go b/data_table_test.go index b327fee..dbbe47d 100644 --- a/data_table_test.go +++ b/data_table_test.go @@ -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) + } +} diff --git a/description_list.go b/description_list.go index 3be4d4b..9d89dca 100644 --- a/description_list.go +++ b/description_list.go @@ -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 { diff --git a/docs/components/070-list.md b/docs/components/070-list.md index 48dc71e..3623837 100644 --- a/docs/components/070-list.md +++ b/docs/components/070-list.md @@ -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` 等滚动位置读取接口。 diff --git a/docs/components/071-data-table.md b/docs/components/071-data-table.md index dce0755..1a3505e 100644 --- a/docs/components/071-data-table.md +++ b/docs/components/071-data-table.md @@ -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)。 diff --git a/docs/components/072-tree.md b/docs/components/072-tree.md index 5e37cb8..7b6c049 100644 --- a/docs/components/072-tree.md +++ b/docs/components/072-tree.md @@ -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` 回到界面线程。 diff --git a/docs/components/073-tree-table.md b/docs/components/073-tree-table.md index 60d8799..dac451b 100644 --- a/docs/components/073-tree-table.md +++ b/docs/components/073-tree-table.md @@ -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 关闭。 ## 限制 diff --git a/docs/components/074-accordion.md b/docs/components/074-accordion.md index 5d02cdc..fe0a242 100644 --- a/docs/components/074-accordion.md +++ b/docs/components/074-accordion.md @@ -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 序列。 diff --git a/docs/components/075-collapsible.md b/docs/components/075-collapsible.md index 12b3cbe..417263d 100644 --- a/docs/components/075-collapsible.md +++ b/docs/components/075-collapsible.md @@ -38,7 +38,7 @@ ui.Render(view, 400, 300, 1) ## 键盘操作 - Enter/Space:展开或关闭。 -- 关闭后内容不在 Tab 序列中。 +- 关闭(包括关闭动画期间)的内容不在 Tab 序列中。 ## 限制 diff --git a/examples/gallery/pages_data.go b/examples/gallery/pages_data.go index 7e7a5df..310a332 100644 --- a/examples/gallery/pages_data.go +++ b/examples/gallery/pages_data.go @@ -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() }) }) diff --git a/examples/gallery/pages_data_collections.go b/examples/gallery/pages_data_collections.go index bfc442c..0b37378 100644 --- a/examples/gallery/pages_data_collections.go +++ b/examples/gallery/pages_data_collections.go @@ -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, diff --git a/examples/gallery/pages_data_content.go b/examples/gallery/pages_data_content.go index 57a4612..92d1b95 100644 --- a/examples/gallery/pages_data_content.go +++ b/examples/gallery/pages_data_content.go @@ -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)) } diff --git a/examples/gallery/pages_data_test.go b/examples/gallery/pages_data_test.go new file mode 100644 index 0000000..8593aeb --- /dev/null +++ b/examples/gallery/pages_data_test.go @@ -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) + } +} diff --git a/image_viewer.go b/image_viewer.go index 30a10e2..2f2fa69 100644 --- a/image_viewer.go +++ b/image_viewer.go @@ -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 diff --git a/list.go b/list.go index bedea8d..0275742 100644 --- a/list.go +++ b/list.go @@ -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)) diff --git a/list_test.go b/list_test.go index d146136..a3eeacb 100644 --- a/list_test.go +++ b/list_test.go @@ -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) + } +} diff --git a/messages_data.go b/messages_data.go index d5fe967..bdc75f2 100644 --- a/messages_data.go +++ b/messages_data.go @@ -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", "警告"}, diff --git a/statistic.go b/statistic.go index 2530b5c..cee212c 100644 --- a/statistic.go +++ b/statistic.go @@ -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) } }) }) diff --git a/statistic_test.go b/statistic_test.go index d72c92b..5bf9951 100644 --- a/statistic_test.go +++ b/statistic_test.go @@ -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) + } +} diff --git a/testdata/snapshots/darwin/data_table-dark.png b/testdata/snapshots/darwin/data_table-dark.png index df9f8da..cda8438 100644 Binary files a/testdata/snapshots/darwin/data_table-dark.png and b/testdata/snapshots/darwin/data_table-dark.png differ diff --git a/testdata/snapshots/darwin/data_table-light.png b/testdata/snapshots/darwin/data_table-light.png index 49c7d33..ee748c6 100644 Binary files a/testdata/snapshots/darwin/data_table-light.png and b/testdata/snapshots/darwin/data_table-light.png differ diff --git a/testdata/snapshots/darwin/list-dark.png b/testdata/snapshots/darwin/list-dark.png index c55bbe6..788678d 100644 Binary files a/testdata/snapshots/darwin/list-dark.png and b/testdata/snapshots/darwin/list-dark.png differ diff --git a/testdata/snapshots/darwin/list-light.png b/testdata/snapshots/darwin/list-light.png index baa6c87..78950f3 100644 Binary files a/testdata/snapshots/darwin/list-light.png and b/testdata/snapshots/darwin/list-light.png differ diff --git a/testdata/snapshots/darwin/tree-dark.png b/testdata/snapshots/darwin/tree-dark.png index 896dd19..e779595 100644 Binary files a/testdata/snapshots/darwin/tree-dark.png and b/testdata/snapshots/darwin/tree-dark.png differ diff --git a/testdata/snapshots/darwin/tree-light.png b/testdata/snapshots/darwin/tree-light.png index 159f119..3709e91 100644 Binary files a/testdata/snapshots/darwin/tree-light.png and b/testdata/snapshots/darwin/tree-light.png differ diff --git a/testdata/snapshots/darwin/tree_table-dark.png b/testdata/snapshots/darwin/tree_table-dark.png index e2eb09e..4080e11 100644 Binary files a/testdata/snapshots/darwin/tree_table-dark.png and b/testdata/snapshots/darwin/tree_table-dark.png differ diff --git a/testdata/snapshots/darwin/tree_table-light.png b/testdata/snapshots/darwin/tree_table-light.png index 8d71e7b..2ee1fb7 100644 Binary files a/testdata/snapshots/darwin/tree_table-light.png and b/testdata/snapshots/darwin/tree_table-light.png differ diff --git a/timeline.go b/timeline.go index 115f077..745e51a 100644 --- a/timeline.go +++ b/timeline.go @@ -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 != "" { diff --git a/tree.go b/tree.go index d659ceb..fb4796f 100644 --- a/tree.go +++ b/tree.go @@ -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()) } diff --git a/tree_table.go b/tree_table.go index 43313bb..6f09ab3 100644 --- a/tree_table.go +++ b/tree_table.go @@ -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) } diff --git a/tree_table_test.go b/tree_table_test.go index 1f4f02a..1013187 100644 --- a/tree_table_test.go +++ b/tree_table_test.go @@ -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) + } +} diff --git a/tree_test.go b/tree_test.go index d258570..77354b2 100644 --- a/tree_test.go +++ b/tree_test.go @@ -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) + } +}