Data: review r2 fixes
This commit is contained in:
12 files changed
+143
-11
No files matched your search
+11
-1
@@ -36,6 +36,8 @@ func (v CarouselView) Changed() bool { return v.changed }
|
||||
type carouselState struct {
|
||||
paused bool
|
||||
next time.Time
|
||||
// dotFocus leaves the arrows to the dots' focus group.
|
||||
dotFocus bool
|
||||
}
|
||||
|
||||
// Carousel shows slide *index of n with previous, next and position controls.
|
||||
@@ -86,6 +88,7 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func
|
||||
c.After(st.next.Sub(now))
|
||||
}
|
||||
switch {
|
||||
case st.dotFocus:
|
||||
case v.Shortcut(0, ui.KeyLeft):
|
||||
goTo(*index - 1)
|
||||
case v.Shortcut(0, ui.KeyRight):
|
||||
@@ -113,10 +116,17 @@ func Carousel(c *ui.Context, index *int, n int, opts CarouselOptions, slide func
|
||||
}
|
||||
})
|
||||
ui.Row(c).Justify(ui.Center).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
st.dotFocus = false
|
||||
ui.Row(c).Gap(u).FocusGroup(ui.Horizontal).Children(func() {
|
||||
for i := range n {
|
||||
if carouselDot(c, i, n, i == *index).Clicked() {
|
||||
dot := carouselDot(c, i, n, i == *index)
|
||||
if dot.Focused() {
|
||||
st.dotFocus = true
|
||||
}
|
||||
// The arrows move the focus among the dots; the slide follows it.
|
||||
if dot.Clicked() || dot.Focused() && i != *index {
|
||||
goTo(i)
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -146,3 +146,29 @@ func TestCarouselEmptyAutoplayKeys(t *testing.T) {
|
||||
t.Fatalf("index %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarouselDotsFollowArrows(t *testing.T) {
|
||||
h := &carouselHarness{}
|
||||
tt := ui.NewTester(h.view, 420, 280)
|
||||
for range 10 {
|
||||
if tt.Focused("Slide 1 of 4") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("Slide 1 of 4") {
|
||||
t.Fatal("Tab never reached the first dot")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.index != 1 || !tt.Focused("Slide 2 of 4") {
|
||||
t.Fatalf("Right: index %d, focus on dot 2 %v", h.index, tt.Focused("Slide 2 of 4"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if h.index != 1 {
|
||||
t.Fatalf("Enter on the focused dot moved to %d", h.index)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.index != 3 || !tt.Focused("Slide 4 of 4") {
|
||||
t.Fatalf("End: index %d", h.index)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -137,7 +137,7 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
v.retry = dataPlaceholder(c, opts.Status, opts.Error, empty, msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
vis, cols := dataTableColumns(c, s, opts)
|
||||
vis, cols := dataTableColumns(dataRowUnit(c), s, opts)
|
||||
s.rows.Key = func(i int) any { return opts.Key(rows[view[i]]) }
|
||||
s.rows.Label = func(i int) string { return opts.Columns[vis[0]].Text(rows[view[i]]) }
|
||||
s.rows.Selected, s.rows.Sort, s.rows.Columns = &s.cursor, &s.Sort, s.Layout
|
||||
@@ -146,7 +146,8 @@ func DataTable[R any, K comparable](c *ui.Context, s *DataTableState[K], rows []
|
||||
s.rows.Selection = &s.Selection
|
||||
}
|
||||
// Rows reach the data-row height of the density.
|
||||
restore := dataSelectTheme(c, d.ControlHeight()/8)
|
||||
unit := dataRowUnit(c)
|
||||
restore := dataSelectTheme(c, unit)
|
||||
head := s.Sort
|
||||
e := ui.Table(c, &s.rows, cols, len(view), func(row, col int) {
|
||||
r := rows[view[row]]
|
||||
@@ -265,8 +266,15 @@ func dataTableMenu[R any, K comparable](opts DataTableOptions[R, K]) []tableColu
|
||||
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()
|
||||
// dataRowUnit is the table spacing unit: rows reach the density's height and fit scaled text.
|
||||
func dataRowUnit(c *ui.Context) float32 {
|
||||
// MyGo rows and the header are 8 units high with 1.5 units of padding above and below.
|
||||
return max(Density(c).ControlHeight()/8, FontSize(c, theme.BodySize)*theme.LineHeight/5)
|
||||
}
|
||||
|
||||
func dataTableColumns[R any, K comparable](unit float32, s *DataTableState[K], opts DataTableOptions[R, K]) ([]int, []ui.TableColumn) {
|
||||
// The check box keeps its full hit width inside MyGo's 2.5-unit cell padding.
|
||||
w := theme.MinHitSize + 5*unit
|
||||
cols := []ui.TableColumn{{ID: dataTableMarkID, Width: w, Fixed: true, Align: ui.Center}}
|
||||
var vis []int
|
||||
for i, col := range opts.Columns {
|
||||
|
||||
+39
-1
@@ -26,6 +26,7 @@ type tableHarness struct {
|
||||
changes int
|
||||
sorts int
|
||||
c *ui.Context
|
||||
density theme.Density
|
||||
seen map[string]int
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ func newTableHarness(rows []relic) *tableHarness {
|
||||
}
|
||||
|
||||
func (h *tableHarness) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
Use(c, Settings{Mode: Light, Density: h.density})
|
||||
h.c, h.seen = c, map[string]int{}
|
||||
ui.Column(c).Padding(10).Gap(8).Children(func() {
|
||||
cols := h.opts
|
||||
@@ -363,3 +364,40 @@ func TestDataTableSelectionColors(t *testing.T) {
|
||||
t.Fatalf("selected row background %v, want Selection %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCompactLargeText(t *testing.T) {
|
||||
h := newTableHarness(relics())
|
||||
h.opts.Multiple = true
|
||||
h.density = theme.Compact
|
||||
tt := ui.NewTester(h.view, 600, 700)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
head, _ := tt.Find("Name")
|
||||
row, _ := tt.Find("Censer") // the first row, labeled by its first column
|
||||
if head.Y+head.H > row.Y-1 {
|
||||
t.Fatalf("header text %v runs past the header into row %v", head, row)
|
||||
}
|
||||
box, ok := tt.Find("Select row")
|
||||
if !ok || box.W < theme.MinHitSize || box.H < theme.MinHitSize || box.X < row.X {
|
||||
t.Fatalf("check box hit area %v in row %v", box, row)
|
||||
}
|
||||
if name, _ := tt.Find("Name"); box.X+box.W > name.X {
|
||||
t.Fatalf("check box %v overlaps the Name column at %v", box, name.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableCheckBoxHitWidth(t *testing.T) {
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Default} {
|
||||
h := newTableHarness(relics())
|
||||
h.opts.Multiple = true
|
||||
h.density = d
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
box, _ := tt.Find("Select row")
|
||||
if box.W < theme.MinHitSize || box.H < theme.MinHitSize {
|
||||
t.Fatalf("%v: check box hit area %v", d, box)
|
||||
}
|
||||
tt.ClickAt(box.X+1, box.Y+box.H/2) // its left edge still toggles
|
||||
if h.st.Selection.Len() != 1 {
|
||||
t.Fatalf("%v: left edge of the hit area missed", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Selection 背景 + Text 文字;单选首列 AccentText 菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行高度不低于当前密度的数据行高(28/36/44 DIP)。
|
||||
默认、表头悬停、排序箭头(升/降)、行悬停、选中行(Selection 背景 + Text 文字;单选首列 AccentText 菱形,多选首列勾选框)、聚焦(表格外焦点环)、加载、追加加载、失败、空、无匹配。数据行与表头高度不低于当前密度的数据行高(28/36/44 DIP),并随文本缩放增高;多选勾选框保留完整的 28 × 28 DIP 命中区域。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ ui.Render(view, 400, 300, 1)
|
||||
## 键盘操作
|
||||
|
||||
- 焦点位于组件内时:←/→ 上一项、下一项;Home/End 第一项、最后一项。
|
||||
- 位置指示为一个焦点组:方向键在指示点间移动,Enter/Space 跳转。
|
||||
- 位置指示为一个焦点组:方向键在指示点间移动,当前项随焦点切换;此时方向键不再由整个组件处理。
|
||||
- Tab 依次到达上一项、下一项、位置指示与暂停按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -76,6 +76,15 @@ func (t *treeCore[K]) begin(label func(K) string, sel *ui.Selection[K], multiple
|
||||
}
|
||||
}
|
||||
|
||||
// clear drops the choice and row state when the tree has no items.
|
||||
func (t *treeCore[K]) clear(sel *ui.Selection[K]) {
|
||||
var zero K
|
||||
t.cursor, t.sel, t.has = -1, zero, false
|
||||
sel.Clear()
|
||||
clear(t.asked)
|
||||
t.outline = ui.OutlineState[K]{Open: t.outline.Open}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -178,6 +187,7 @@ func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) Tre
|
||||
v.Element = ui.Column(c).MinHeight(0)
|
||||
v.Children(func() {
|
||||
if len(opts.Roots) == 0 {
|
||||
t.clear(&s.Selection)
|
||||
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
@@ -199,7 +209,9 @@ func Tree[K comparable](c *ui.Context, s *TreeState[K], opts TreeOptions[K]) Tre
|
||||
v.changed, v.submitted = e.Changed(), e.Submitted()
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
})
|
||||
t.end(c, v.changed)
|
||||
if len(opts.Roots) > 0 {
|
||||
t.end(c, v.changed)
|
||||
}
|
||||
if v.changed {
|
||||
slog.Debug("mujica: tree changed", "row", t.cursor, "count", s.Selection.Len())
|
||||
}
|
||||
|
||||
+5
-2
@@ -124,6 +124,7 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
v.Element = ui.Column(c).MinHeight(0).Gap(d.Unit() * 2)
|
||||
v.Children(func() {
|
||||
if len(opts.Roots) == 0 {
|
||||
t.clear(&s.Selection)
|
||||
v.retry = dataPlaceholder(c, opts.Status, opts.Error, opts.Empty, msg(c, "list.empty"))
|
||||
return
|
||||
}
|
||||
@@ -137,7 +138,7 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
roots := sorted(opts.Roots, &s.roots)
|
||||
head := s.Sort
|
||||
t.outline.List.Sort, t.outline.List.Columns = &s.Sort, s.Layout
|
||||
restore := dataSelectTheme(c, d.ControlHeight()/8)
|
||||
restore := dataSelectTheme(c, dataRowUnit(c))
|
||||
e := ui.OutlineTable(c, &t.outline, cols, roots, children, func(item K, col int) {
|
||||
if col > 0 {
|
||||
opts.Columns[col].Cell(item)
|
||||
@@ -160,7 +161,9 @@ func TreeTable[K comparable](c *ui.Context, s *TreeTableState[K], opts TreeTable
|
||||
v.retry = dataFooter(c, opts.Status, opts.Error)
|
||||
})
|
||||
v.sorted = s.Sort != before
|
||||
t.end(c, v.changed)
|
||||
if len(opts.Roots) > 0 {
|
||||
t.end(c, v.changed)
|
||||
}
|
||||
if v.sorted {
|
||||
slog.Debug("mujica: tree_table sort", "column", s.Sort.Column, "descending", s.Sort.Descending)
|
||||
}
|
||||
|
||||
@@ -244,3 +244,20 @@ func TestTreeTableColumnMenuKeyboard(t *testing.T) {
|
||||
t.Fatalf("rows not sorted by size: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTableEmptyClearsSelection(t *testing.T) {
|
||||
h := newTTHarness()
|
||||
h.opts.Multiple = true
|
||||
tt := ui.NewTester(h.view, 400, 360)
|
||||
if err := tt.Click("Organ"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k, ok := h.st.Selected(); !ok || k != "Organ" || h.st.Selection.Len() != 1 {
|
||||
t.Fatalf("selected %q %v", k, ok)
|
||||
}
|
||||
h.opts.Roots = nil
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok || h.st.Selection.Len() != 0 {
|
||||
t.Fatalf("empty table still selects %q (%d chosen)", k, h.st.Selection.Len())
|
||||
}
|
||||
}
|
||||
@@ -252,3 +252,21 @@ func TestTreeSelectionFollowsLargeReorder(t *testing.T) {
|
||||
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, st.tree.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeEmptyClearsSelection(t *testing.T) {
|
||||
h := newTreeHarness()
|
||||
tt := ui.NewTester(h.view, 400, 400)
|
||||
if err := tt.Click("Sacristy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.opts.Roots = nil
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok {
|
||||
t.Fatalf("empty tree still selects %q", k)
|
||||
}
|
||||
h.opts.Roots = []string{"Nave", "Crypt", "Sacristy"}
|
||||
tt.Frame()
|
||||
if k, ok := h.st.Selected(); ok {
|
||||
t.Fatalf("selection came back as %q", k)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user