diff --git a/carousel.go b/carousel.go index 05f4587..2b27b87 100644 --- a/carousel.go +++ b/carousel.go @@ -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() } } }) diff --git a/carousel_test.go b/carousel_test.go index aae5119..59d8435 100644 --- a/carousel_test.go +++ b/carousel_test.go @@ -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) + } +} diff --git a/data_table.go b/data_table.go index 86ca4d6..508b20a 100644 --- a/data_table.go +++ b/data_table.go @@ -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 { diff --git a/data_table_test.go b/data_table_test.go index dbbe47d..c153dee 100644 --- a/data_table_test.go +++ b/data_table_test.go @@ -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) + } + } +} diff --git a/docs/components/071-data-table.md b/docs/components/071-data-table.md index 1a3505e..68ab9d8 100644 --- a/docs/components/071-data-table.md +++ b/docs/components/071-data-table.md @@ -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 命中区域。 ## 事件 diff --git a/docs/components/080-carousel.md b/docs/components/080-carousel.md index 51cc923..e9db9e3 100644 --- a/docs/components/080-carousel.md +++ b/docs/components/080-carousel.md @@ -39,7 +39,7 @@ ui.Render(view, 400, 300, 1) ## 键盘操作 - 焦点位于组件内时:←/→ 上一项、下一项;Home/End 第一项、最后一项。 -- 位置指示为一个焦点组:方向键在指示点间移动,Enter/Space 跳转。 +- 位置指示为一个焦点组:方向键在指示点间移动,当前项随焦点切换;此时方向键不再由整个组件处理。 - Tab 依次到达上一项、下一项、位置指示与暂停按钮。 ## 限制 diff --git a/testdata/snapshots/darwin/data_table-dark.png b/testdata/snapshots/darwin/data_table-dark.png index cda8438..87ab18b 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 ee748c6..a701a7a 100644 Binary files a/testdata/snapshots/darwin/data_table-light.png and b/testdata/snapshots/darwin/data_table-light.png differ diff --git a/tree.go b/tree.go index fb4796f..efd0c91 100644 --- a/tree.go +++ b/tree.go @@ -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()) } diff --git a/tree_table.go b/tree_table.go index 6f09ab3..41e4ba7 100644 --- a/tree_table.go +++ b/tree_table.go @@ -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) } diff --git a/tree_table_test.go b/tree_table_test.go index 1013187..8a3c572 100644 --- a/tree_table_test.go +++ b/tree_table_test.go @@ -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()) + } +} diff --git a/tree_test.go b/tree_test.go index 77354b2..e238b3a 100644 --- a/tree_test.go +++ b/tree_test.go @@ -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) + } +}