From 01f5c1ca08c0625e0d400d8c69100fb2d4d9c3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 13:04:05 -0400 Subject: [PATCH] Navigation: review r2 fixes --- breadcrumb.go | 1 + command_palette.go | 3 +- command_palette_test.go | 23 ++++++++ docs/components/061-breadcrumb.md | 1 + docs/components/062-pagination.md | 1 + docs/components/065-navigation-menu.md | 2 + docs/components/066-menubar.md | 2 + docs/components/068-command-palette.md | 2 + internal/navigation.go | 12 +++++ menubar.go | 1 + menubar_panel.go | 8 +++ menubar_test.go | 28 ++++++++++ navigation_menu.go | 64 +++++++++++++---------- navigation_review_test.go | 72 ++++++++++++++++++++++++++ pagination.go | 4 +- toolbar.go | 3 +- 16 files changed, 196 insertions(+), 31 deletions(-) diff --git a/breadcrumb.go b/breadcrumb.go index 3ea31e2..d0977fb 100644 --- a/breadcrumb.go +++ b/breadcrumb.go @@ -135,6 +135,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { } b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) }) ui.PopoverBase(c, b, open, func(panel *ui.Element) { + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(160).MaxWidth(320) for j, it := range hidden { diff --git a/command_palette.go b/command_palette.go index f440299..1237bbe 100644 --- a/command_palette.go +++ b/command_palette.go @@ -81,6 +81,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { ww, wh := c.Size() back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start) + internal.PopIn(c, back, Motion(c, theme.PopupDuration)) panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76). Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(msg(c, "command_palette.label")) @@ -92,7 +93,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP }) ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone) if in.Changed() { - st.hl = 0 + st.hl, st.reveal = 0, true } n := len(found) switch { diff --git a/command_palette_test.go b/command_palette_test.go index 5384491..a7278ff 100644 --- a/command_palette_test.go +++ b/command_palette_test.go @@ -1,6 +1,7 @@ package mujica import ( + "fmt" "testing" "github.com/egoist/mygo/ui" @@ -100,6 +101,28 @@ func TestCommandPaletteAnnouncesHighlight(t *testing.T) { } } +func TestCommandPaletteFilterRevealsHighlight(t *testing.T) { + var cmds []Command + for i := range 30 { + cmds = append(cmds, Command{ID: fmt.Sprint(i), Label: fmt.Sprintf("Command %02d", i), Keywords: []string{"royal"}}) + } + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CommandPalette(c, &open, cmds, CommandPaletteOptions{}) + }, 800, 600) + tt.Key(0, ui.KeyUp) // wraps to the last, scrolling to the bottom + tt.Frame() + if r, _ := tt.Find("Command 00"); r.H > 0 { + t.Fatalf("list did not scroll to the end: first row at %+v", r) + } + tt.Type("royal") + tt.Frame() + if r, _ := tt.Find("Command 00"); r.H == 0 { + t.Fatal("highlighted first match is off screen after filtering") + } +} + func TestCommandPaletteEmptyClickEscape(t *testing.T) { a := &paletteApp{open: true} tt := ui.NewTester(a.view, 800, 600) diff --git a/docs/components/061-breadcrumb.md b/docs/components/061-breadcrumb.md index 15240cd..482f51a 100644 --- a/docs/components/061-breadcrumb.md +++ b/docs/components/061-breadcrumb.md @@ -55,3 +55,4 @@ if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok - 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。 - 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 diff --git a/docs/components/062-pagination.md b/docs/components/062-pagination.md index 36943b7..1b4e370 100644 --- a/docs/components/062-pagination.md +++ b/docs/components/062-pagination.md @@ -51,3 +51,4 @@ if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageS ## 限制 - 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 diff --git a/docs/components/065-navigation-menu.md b/docs/components/065-navigation-menu.md index 630ca64..032f112 100644 --- a/docs/components/065-navigation-menu.md +++ b/docs/components/065-navigation-menu.md @@ -59,3 +59,5 @@ router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) }) - MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。 - 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 +- 下拉面板最高为锚点上方或下方可用空间,超出时在面板内滚动;方向键移动焦点时自动滚入视野。 diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md index 985d943..76ca596 100644 --- a/docs/components/066-menubar.md +++ b/docs/components/066-menubar.md @@ -72,3 +72,5 @@ if id, ok := res.Chosen(); ok && id == "wrap" { - MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。 - 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。 - 菜单打开期间,焦点保持在标题上,高亮项由组件管理。驱动菜单的按键注册在标题上,而 MyGo 的 active descendant 不公开,因此高亮变化时用 `c.Announce` 朗读当前项,并把它写入标题的 `Description`。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 +- 点击菜单内任意位置(包括禁用项)或用指针打开子菜单后,焦点回到所属标题,方向键、Enter、Escape 继续有效。 diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md index 85b5911..c232838 100644 --- a/docs/components/068-command-palette.md +++ b/docs/components/068-command-palette.md @@ -64,3 +64,5 @@ if id, ok := res.Chosen(); ok { - MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。 - 搜索是子串匹配,不做模糊匹配或排序打分。 - 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。 +- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。 +- 搜索内容变化后高亮回到第一项,并滚入视野。 diff --git a/internal/navigation.go b/internal/navigation.go index d5f4841..8564599 100644 --- a/internal/navigation.go +++ b/internal/navigation.go @@ -17,3 +17,15 @@ func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element { } return e.TextColor(ui.Color{R: ch("mujica.fg.r", fg.R), G: ch("mujica.fg.g", fg.G), B: ch("mujica.fg.b", fg.B), A: ch("mujica.fg.a", fg.A)}) } + +// PopIn fades popup e in over d from the frame it appeared and returns the progress, 0 to 1. +func PopIn(c *ui.Context, e *ui.Element, d time.Duration) float32 { + born := ui.Local(e, "mujica.popin", func() time.Time { return c.Now() }) + p := float32(1) + if t := float32(c.Now().Sub(*born)) / float32(max(d, 1)); d > 0 && t < 1 { + p = ui.EaseOut(t) + c.AnimationFrame() + } + e.Opacity(p) + return p +} diff --git a/menubar.go b/menubar.go index d27b21e..5a04be4 100644 --- a/menubar.go +++ b/menubar.go @@ -285,6 +285,7 @@ func (m *menubarRun) activate(l, h int) { } if it.Items != nil { m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1)) + m.focus(m.st.open) return } m.chosen = it.ID diff --git a/menubar_panel.go b/menubar_panel.go index 1ea06b1..cecba9c 100644 --- a/menubar_panel.go +++ b/menubar_panel.go @@ -64,6 +64,7 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u panel := ui.Scroll(c).Absolute().Padding(u).MinWidth(200).MaxHeight(wh-8).AlignItems(ui.Stretch).Role(ui.RolePopup). Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18)) + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) pb := panel.Bounds() if x+pb.W > ww-4 { x = ww - 4 - pb.W @@ -75,6 +76,9 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u y = max(4, wh-4-pb.H) } panel.Left(max(4, x)).Top(y) + if panel.Clicked() { + m.focus(st.open) + } var anchor *ui.Element panel.Children(func() { for j, it := range items { @@ -95,6 +99,10 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u m.activate(l, j) return } + if row.Pressed() { + // A press takes the focus off the title, which drives the keys. + m.focus(st.open) + } px, py, over := row.PointerPosition() b := row.Bounds() at := [2]float32{b.X + px, b.Y + py} diff --git a/menubar_test.go b/menubar_test.go index 2f97d12..db09c7a 100644 --- a/menubar_test.go +++ b/menubar_test.go @@ -150,6 +150,34 @@ func TestMenubarKeyboard(t *testing.T) { } } +func TestMenubarKeysAfterClickInMenu(t *testing.T) { + a := &menubarApp{} + tt := ui.NewTester(a.view, 700, 500) + _ = tt.Click("File") + if err := tt.Click("Export"); err != nil { + t.Fatal(err) + } + if !tt.Focused("File") { + t.Fatal("click into the menu left the title without focus") + } + tt.Key(0, ui.KeyDown) // PDF to Vector, skipping disabled Image + tt.Key(0, ui.KeyEnter) + if len(a.chosen) != 1 || a.chosen[0] != "svg" { + t.Fatalf("arrows after a click chose %q", a.chosen) + } + _ = tt.Click("File") + _ = tt.Click("Export") + _ = tt.Click("Image") // disabled: the press still keeps keys working + tt.Key(0, ui.KeyEscape) + if tt.HasText("Vector") || !tt.HasText("Quit") { + t.Fatalf("Escape did not close only the submenu: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Quit") { + t.Fatal("second Escape did not close the menu") + } +} + func TestMenubarLongMenuScrolls(t *testing.T) { var items []MenubarItem for i := range 30 { diff --git a/navigation_menu.go b/navigation_menu.go index 94783ad..af67349 100644 --- a/navigation_menu.go +++ b/navigation_menu.go @@ -147,36 +147,48 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s *open = true } ui.PopoverBase(c, b, open, func(panel *ui.Element) { - panel.Margin(u, 0, 0, 0).Padding(u*2).Gap(u).Radius(theme.PopupRadius).Background(k.Surface). + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) + // Capped to the room beside the anchor; the links scroll inside. + _, wh := c.Size() + r := b.Bounds() + panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).MaxHeight(max(r.Y, wh-r.Y-r.H)-16). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(240).MaxWidth(360) - panel.FocusGroup(ui.Vertical) - first := true - var list func(items []NavigationLink) - list = func(items []NavigationLink) { - for _, it := range items { - if it.Label == "" { - panic("mujica: NavigationLink needs a Label") - } - if it.Items != nil { - ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)). - Padding(u*2, u*2, u, u*2).SingleLine() - list(it.Items) - continue - } - if it.Path == "" { - panic("mujica: NavigationLink needs a Path or Items") - } - if navigationMenuItem(c, it, it.Path == path, first).Clicked() { - *open = false - push(it.Path) - } - first = false - } - } - list(l.Items) + ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() { + navigationMenuPanel(c, l.Items, path, open, push) + }) }) } +// navigationMenuPanel lists a group's links, its subgroups under serif headings. +func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) { + k := Tokens(c) + u := Density(c).Unit() + first := true + var list func(items []NavigationLink) + list = func(items []NavigationLink) { + for _, it := range items { + if it.Label == "" { + panic("mujica: NavigationLink needs a Label") + } + if it.Items != nil { + ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)). + Padding(u*2, u*2, u, u*2).SingleLine() + list(it.Items) + continue + } + if it.Path == "" { + panic("mujica: NavigationLink needs a Path or Items") + } + if navigationMenuItem(c, it, it.Path == path, first).Clicked() { + *open = false + push(it.Path) + } + first = false + } + } + list(items) +} + func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element { k := Tokens(c) u := Density(c).Unit() diff --git a/navigation_review_test.go b/navigation_review_test.go index ff29221..4ea939f 100644 --- a/navigation_review_test.go +++ b/navigation_review_test.go @@ -1,13 +1,85 @@ package mujica import ( + "fmt" "testing" + "time" "github.com/egoist/mygo/ui" "mujicaui/icons" ) +// TestNavigationPopupsFadeIn checks each popup fades in over PopupDuration instead of appearing at once. +func TestNavigationPopupsFadeIn(t *testing.T) { + for _, tc := range []struct { + name, open, sample string + view func(c *ui.Context) + }{ + {"breadcrumb", "Show hidden path", "Alpha", func(c *ui.Context) { + Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{}) + }}, + {"pagination", "10 / page", "50 / page", func(c *ui.Context) { + page, size := 1, 10 + Pagination(c, &page, &size, PaginationOptions{Total: 100, PageSizes: []int{10, 50}}) + }}, + {"navigation menu", "Library", "Letters", func(c *ui.Context) { + NavigationMenu(c, ui.NewRouter("/"), navigationMenuSample(), NavigationMenuOptions{}) + }}, + {"menubar", "File", "Quit", func(c *ui.Context) { + Menubar(c, (&menubarApp{}).menus(), MenubarOptions{}) + }}, + {"command palette", "", "Esc close", func(c *ui.Context) { + open := true + CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{}) + }}, + } { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) }) + }, 700, 500) + if tc.open != "" { + if err := tt.Click(tc.open); err != nil { + t.Fatal(err) + } + } + r, ok := tt.Find(tc.sample) + if !ok { + t.Fatalf("%s: no %q", tc.name, tc.sample) + } + x, y := int(r.X-3), int(r.Y+r.H/2) + early := tt.Image().RGBAAt(x, y) + time.Sleep(200 * time.Millisecond) + tt.Frame() + if late := tt.Image().RGBAAt(x, y); early == late { + t.Errorf("%s: popup appeared at once (%v)", tc.name, late) + } + } +} + +// TestNavigationMenuLongPanelScrolls checks a tall group stays in the window and follows the focus. +func TestNavigationMenuLongPanelScrolls(t *testing.T) { + var links []NavigationLink + for i := range 30 { + links = append(links, NavigationLink{Label: fmt.Sprintf("Link %02d", i), Path: fmt.Sprintf("/l/%d", i)}) + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NavigationMenu(c, ui.NewRouter("/"), []NavigationLink{{Label: "Group", Items: links}}, NavigationMenuOptions{}) + }, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + tt.Frame() + if !tt.Focused("Link 29") { + t.Fatal("End did not focus the last link") + } + r, _ := tt.Find("Link 29") + if r.H == 0 || r.Y+r.H > 300 { + t.Fatalf("focused link off screen at %+v", r) + } +} + // TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale. func TestNavigationCaptionsFollowTextScale(t *testing.T) { for _, tc := range []struct { diff --git a/pagination.go b/pagination.go index f025bd3..f73b1aa 100644 --- a/pagination.go +++ b/pagination.go @@ -36,8 +36,7 @@ type PaginationResult struct { // Changed reports a new page or page size in this frame, from the user or a correction. func (r PaginationResult) Changed() bool { return r.changed } -// Pagination moves *page (from 1) through Total records, *pageSize per page. -// A page past the end after Total shrinks is corrected to the last page. +// Pagination pages *page (from 1) through Total records, clamping it when Total shrinks. func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult { if opts.Total < 0 { panic("mujica: Pagination Total is negative") @@ -185,6 +184,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) }) sel.Popup(func(panel *ui.Element) { + internal.PopIn(c, panel, Motion(c, theme.PopupDuration)) panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface). Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)) for _, s := range sizes { diff --git a/toolbar.go b/toolbar.go index 2982c3f..fff99f8 100644 --- a/toolbar.go +++ b/toolbar.go @@ -13,8 +13,7 @@ type ToolbarOptions struct { Label string } -// Toolbar is a row of actions built by fn, one Tab stop with arrow keys inside. -// Actions that do not fit move, from the last, into an overflow menu. +// Toolbar is one Tab stop of actions; those that do not fit go to an overflow menu. func Toolbar(c *ui.Context, opts ToolbarOptions, fn func()) *ui.Element { k := Tokens(c) tb := ui.Toolbar(c, fn).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).