diff --git a/breadcrumb.go b/breadcrumb.go index 1bfd93b..3ea31e2 100644 --- a/breadcrumb.go +++ b/breadcrumb.go @@ -90,10 +90,12 @@ func Breadcrumb(c *ui.Context, items []BreadcrumbItem, opts BreadcrumbOptions) B link := ui.ButtonBase(c).Role(ui.RoleLink).Gap(u).Padding(u, u*1.5).Radius(theme.ControlRadius). Shrink(1).MinWidth(0).Cursor(ui.CursorPointer).Tooltip(it.Label).Label(it.Label) internal.Ring(link, k.Focus, theme.ControlRadius) - link.TextColor(k.TextMuted) + fg := k.TextMuted if link.Hovered() { - link.Background(k.SurfaceHover).TextColor(k.Text) + link.Background(k.SurfaceHover) + fg = k.Text } + internal.Ease(link, Motion(c, theme.StateDuration), fg) link.Children(func() { breadcrumbContent(c, it, width) }) if link.Clicked() { choose(i) @@ -119,12 +121,15 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { k := Tokens(c) u := Density(c).Unit() b := ui.ButtonBase(c).Padding(u, u*1.5).Radius(theme.ControlRadius).MinWidth(theme.MinHitSize). - Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).TextColor(k.TextMuted).Cursor(ui.CursorPointer) + Label(msg(c, "breadcrumb.more")).Tooltip(msg(c, "breadcrumb.more")).Cursor(ui.CursorPointer) internal.Ring(b, k.Focus, theme.ControlRadius) open := ui.Local(b, "open", func() bool { return false }) + fg := k.TextMuted if b.Hovered() || *open { - b.Background(k.SurfaceHover).TextColor(k.Text) + b.Background(k.SurfaceHover) + fg = k.Text } + internal.Ease(b, Motion(c, theme.StateDuration), fg) if b.Clicked() { *open = !*open } @@ -142,6 +147,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) { if item.Hovered() { item.Background(k.SurfaceHover) } + internal.Ease(item, Motion(c, theme.StateDuration), k.Text) item.Children(func() { if it.Icon != nil { ui.Icon(c, it.Icon).Size(14, 14).Shrink(0) diff --git a/command_palette.go b/command_palette.go index e49f66f..f440299 100644 --- a/command_palette.go +++ b/command_palette.go @@ -8,6 +8,7 @@ import ( "github.com/egoist/mygo/ui" "mujicaui/icons" + "mujicaui/internal" "mujicaui/theme" ) @@ -102,6 +103,15 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP st.hl, st.reveal = (st.hl-1+n)%n, true } st.hl = max(0, min(st.hl, n-1)) + // Focus stays in the search box, so the highlighted command is read out. + if n > 0 { + in.Description(found[st.hl].Label) + if st.reveal { + c.Announce(found[st.hl].Label) + } + } else { + in.Description(msg(c, "command_palette.empty")) + } run := -1 if in.Submitted() && n > 0 { run = st.hl @@ -118,7 +128,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP if cmd.Group != group { group = cmd.Group if group != "" { - ui.Text(c, group).Font(theme.SerifFont).FontSize(theme.CaptionSize). + ui.Text(c, group).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() } } @@ -129,7 +139,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP }) ui.Row(c).Gap(u*4).Padding(u*2, u*4).Background(k.Background).Children(func() { for _, hint := range []string{"command_palette.hint_move", "command_palette.hint_run", "command_palette.hint_close"} { - ui.Text(c, msg(c, hint)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine() + ui.Text(c, msg(c, hint)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() } }) if run >= 0 { @@ -162,6 +172,7 @@ func commandPaletteRow(c *ui.Context, cmd Command, i int, st *commandPaletteStat st.reveal = false } } + internal.Ease(row, Motion(c, theme.StateDuration), k.Text) px, py, over := row.PointerPosition() b := row.Bounds() if at := [2]float32{b.X + px, b.Y + py}; over && at != st.ptr { diff --git a/command_palette_test.go b/command_palette_test.go index 724250f..5384491 100644 --- a/command_palette_test.go +++ b/command_palette_test.go @@ -89,6 +89,17 @@ func TestCommandPaletteShortcutSearchRun(t *testing.T) { } } +func TestCommandPaletteAnnouncesHighlight(t *testing.T) { + a := &paletteApp{open: true} + tt := ui.NewTester(a.view, 800, 600) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyDown) + got := tt.Announcements() + if len(got) < 2 || got[len(got)-2] != "Open recent" || got[len(got)-1] != "Save all" { + t.Fatalf("announcements %q", got) + } +} + func TestCommandPaletteEmptyClickEscape(t *testing.T) { a := &paletteApp{open: true} tt := ui.NewTester(a.view, 800, 600) diff --git a/docs/components/059-tabs.md b/docs/components/059-tabs.md index f99cb30..436f668 100644 --- a/docs/components/059-tabs.md +++ b/docs/components/059-tabs.md @@ -41,7 +41,7 @@ mujica.Tabs(c, &tab, []mujica.Tab{ ## 状态 - 选中:Selection 背景、2 DIP AccentText 边线(横向在底边,纵向在右边)、600 字重,三者同时标记。 -- 悬停:SurfaceHover 背景;按下:Selection 背景。 +- 悬停:SurfaceHover 背景;按下:Selection 背景。背景与文字颜色在 120 ms 内过渡,减少动画时直接切换。 - 焦点:2 DIP Focus 焦点环,外留 2 DIP。 - 禁用:MyGo 统一降低不透明度,不响应指针与键盘。 - 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。 diff --git a/docs/components/064-sidebar.md b/docs/components/064-sidebar.md index f32e7b8..ff174ed 100644 --- a/docs/components/064-sidebar.md +++ b/docs/components/064-sidebar.md @@ -62,10 +62,10 @@ mujica.Sidebar(c, &selected, []mujica.SidebarSection{ - Tab:进入侧边栏(一个停靠点)。 - ↑/↓:上一项/下一项;Home / End:首项/末项;输入名称开头字母跳到匹配项。 -- 分组与嵌套组标题用指针点击展开折叠。 +- 分组与嵌套组标题各是一个 Tab 停靠点(辅助技术看到 disclosure,含展开状态);Enter 或 Space 展开折叠,箭头在 120 ms 内转动。 ## 限制 - 复用 MyGo `ui.SidebarItem`,它用主题 Accent 绘制图标(`ui/sidebar.go:182`);深色主题 Accent 对比度不足,本组件在构建侧边栏时临时把 MyGo 主题的 Accent 换成 AccentText。 - MyGo `SidebarItem` 总是追加名称文字(`ui/sidebar.go:212`);紧凑模式传入空名称,名称改由 `Label` 与提示提供,因此紧凑模式下首字母跳转不可用。 -- 嵌套组用 MyGo `SidebarSection` 实现,组标题本身不能被选中,且只能用指针展开折叠(MyGo 不给标题注册按键)。 +- 组标题基于 MyGo `CollapsibleBase` 的触发器,而不是 `SidebarSection`(后者只能用指针展开,箭头固定 150 ms,`ui/sidebar.go:115`)。组标题本身不能被选中。展开状态按完整父路径保存,同名组互不影响。 diff --git a/docs/components/066-menubar.md b/docs/components/066-menubar.md index 6f45bba..985d943 100644 --- a/docs/components/066-menubar.md +++ b/docs/components/066-menubar.md @@ -52,7 +52,7 @@ if id, ok := res.Chosen(); ok && id == "wrap" { - 打开的菜单标题:Selection 背景;高亮项:Selection 背景。 - 勾选项:AccentText 勾号;单选项:AccentText 菱形标记;名称附“已勾选”。 - 禁用项:降低不透明度,指针与键盘均跳过。 -- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时上移。 +- 弹层:6 DIP 圆角、Border 边框;靠近窗口边缘时左移,子菜单放不下时翻到父菜单左侧,过高时限高并在菜单内滚动,键盘高亮项自动滚入视野。 ## 事件 @@ -71,4 +71,4 @@ if id, ok := res.Chosen(); ok && id == "wrap" { - 快捷键只做提示,不注册;用 `c.Shortcut` 处理。 - MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。 - 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。 -- 菜单打开期间,焦点保持在标题上,高亮项由组件管理(与 MyGo `SelectBase` 相同的方式)。 +- 菜单打开期间,焦点保持在标题上,高亮项由组件管理。驱动菜单的按键注册在标题上,而 MyGo 的 active descendant 不公开,因此高亮变化时用 `c.Announce` 朗读当前项,并把它写入标题的 `Description`。 diff --git a/docs/components/068-command-palette.md b/docs/components/068-command-palette.md index a249aeb..85b5911 100644 --- a/docs/components/068-command-palette.md +++ b/docs/components/068-command-palette.md @@ -63,3 +63,4 @@ if id, ok := res.Chosen(); ok { - MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。 - 搜索是子串匹配,不做模糊匹配或排序打分。 +- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。 diff --git a/internal/navigation.go b/internal/navigation.go new file mode 100644 index 0000000..d5f4841 --- /dev/null +++ b/internal/navigation.go @@ -0,0 +1,19 @@ +package internal + +import ( + "math" + "time" + + "github.com/egoist/mygo/ui" +) + +// Ease moves e's background, border and text color fg to their targets over d. +func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element { + if d > 0 { + e.Transition(ui.ElementTransition{Colors: true, Duration: d}) + } + ch := func(key string, v uint8) uint8 { + return uint8(math.Round(float64(e.Animate(key, float32(v), d)))) + } + 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)}) +} diff --git a/menubar.go b/menubar.go index 00f63f2..d27b21e 100644 --- a/menubar.go +++ b/menubar.go @@ -68,6 +68,8 @@ type menubarState struct { hl []int focusTo int ptr [2]float32 + // reveal scrolls the highlighted item into view once. + reveal bool } // Menubar is an in-window menu bar drawn by MujicaUI, with submenus and full keyboard control. @@ -106,6 +108,10 @@ func Menubar(c *ui.Context, menus []MenubarMenu, opts MenubarOptions) MenubarRes if m.st.open >= 0 { m.overlay(titles) } + // Focus stays on the title, so the highlighted item is read out. + if name := m.current(); m.moved && name != "" { + c.Announce(name) + } res.chosen = m.chosen return res } @@ -136,6 +142,20 @@ type menubarRun struct { menus []MenubarMenu st *menubarState chosen string + moved bool +} + +// current names the highlighted item of the open menu, "" for none. +func (m *menubarRun) current() string { + st := m.st + if st.open < 0 || len(st.hl) == 0 { + return "" + } + l := len(st.hl) - 1 + if h := st.hl[l]; h >= 0 { + return menubarName(m.c, m.items(l)[h]) + } + return "" } func (m *menubarRun) title(i int, label string) *ui.Element { @@ -152,6 +172,7 @@ func (m *menubarRun) title(i int, label string) *ui.Element { case t.Hovered(): t.Background(k.SurfaceHover) } + internal.Ease(t, Motion(c, theme.StateDuration), k.Text) if st.focusTo == i { t.Focus() st.focusTo = -1 @@ -165,8 +186,9 @@ func (m *menubarRun) title(i int, label string) *ui.Element { case t.Shortcut(0, ui.KeyDown), t.Shortcut(0, ui.KeyUp): m.openMenu(i, true) } - } else { + } else if open { m.keys(t) + t.Description(m.current()) } if t.Clicked() { if open { @@ -185,6 +207,7 @@ func (m *menubarRun) keys(t *ui.Element) { l := len(st.hl) - 1 items := m.items(l) h := st.hl[l] + m.moved, st.reveal = true, true switch { case t.Shortcut(0, ui.KeyDown): st.hl[l] = menubarStep(items, h, 1) @@ -216,6 +239,8 @@ func (m *menubarRun) keys(t *ui.Element) { } else { m.close() } + default: + m.moved, st.reveal = false, false } } @@ -239,6 +264,7 @@ func (m *menubarRun) openMenu(i int, highlight bool) { first = menubarStep(m.menus[i].Items, -1, 1) } m.st.open, m.st.hl = i, []int{first} + m.moved = highlight m.focus(i) slog.Debug("mujica: menubar open", "menu", m.menus[i].Label) } diff --git a/menubar_panel.go b/menubar_panel.go index 11e7185..1ea06b1 100644 --- a/menubar_panel.go +++ b/menubar_panel.go @@ -53,18 +53,18 @@ func (m *menubarRun) overlay(titles []ui.Rect) { }) } -// panel builds level l at (x, y), flipping left of parent when it does not fit. -// It returns the panel and the row of its highlighted item. +// panel builds level l at (x, y) and returns it with its highlighted row. func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *ui.Element) { c, st := m.c, m.st k := Tokens(c) u := Density(c).Unit() items := m.items(l) - panel := ui.Column(c).Absolute().Padding(u).MinWidth(200).AlignItems(ui.Stretch).Role(ui.RolePopup). + ww, wh := c.Size() + // Long menus scroll within the window instead of running off it. + 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)) pb := panel.Bounds() - ww, wh := c.Size() if x+pb.W > ww-4 { x = ww - 4 - pb.W if l > 0 { @@ -85,6 +85,10 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u row := m.item(it, st.hl[l] == j) if st.hl[l] == j { anchor = row + if st.reveal && l == len(st.hl)-1 { + row.ScrollIntoView() + st.reveal = false + } } if row.Clicked() { st.hl = append(st.hl[:l], j) @@ -114,15 +118,13 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element { k := Tokens(c) d := Density(c) u := d.Unit() - name := it.Label - if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked { - name += ", " + msg(c, "menubar.checked") - } + name := menubarName(c, it) row := ui.Row(c).AlignItems(ui.Center).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2). Radius(theme.ControlRadius).Label(name).TextColor(k.Text).Disabled(it.Disabled).Role(ui.RoleButton) if hl { row.Background(k.Selection) } + internal.Ease(row, Motion(c, theme.StateDuration), k.Text) row.Children(func() { mark := ui.Box(c).Size(16, 16).Shrink(0).Center().Role(ui.RoleNone) if it.Checked && it.Kind == MenubarCheck { @@ -136,7 +138,7 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element { ui.Text(c, it.Label).SingleLine() ui.Box(c).Grow(1).MinWidth(u * 6) if it.Shortcut.Key != 0 { - ui.Text(c, menubarShortcutText(it.Shortcut)).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine() + ui.Text(c, menubarShortcutText(it.Shortcut)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() } if it.Items != nil { ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.TextMuted) @@ -145,6 +147,14 @@ func (m *menubarRun) item(it MenubarItem, hl bool) *ui.Element { return row } +// menubarName is an item's label, with its checked state for assistive technology. +func menubarName(c *ui.Context, it MenubarItem) string { + if (it.Kind == MenubarCheck || it.Kind == MenubarRadio) && it.Checked { + return it.Label + ", " + msg(c, "menubar.checked") + } + return it.Label +} + var menubarKeyNames = map[ui.Key]string{ ui.KeyEnter: "Enter", ui.KeyEscape: "Esc", ui.KeyBackspace: "Backspace", ui.KeyTab: "Tab", ui.KeySpace: "Space", ui.KeyDelete: "Del", ui.KeyHome: "Home", ui.KeyEnd: "End", ui.KeyPageUp: "PgUp", ui.KeyPageDown: "PgDn", diff --git a/menubar_test.go b/menubar_test.go index 1309072..2f97d12 100644 --- a/menubar_test.go +++ b/menubar_test.go @@ -1,6 +1,7 @@ package mujica import ( + "fmt" "runtime" "testing" @@ -149,6 +150,38 @@ func TestMenubarKeyboard(t *testing.T) { } } +func TestMenubarLongMenuScrolls(t *testing.T) { + var items []MenubarItem + for i := range 30 { + items = append(items, MenubarItem{ID: fmt.Sprint(i), Label: fmt.Sprintf("Entry %02d", i)}) + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Menubar(c, []MenubarMenu{{Label: "Long", Items: items}}, MenubarOptions{}) + }, 400, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + tt.Frame() + r, ok := tt.Find("Entry 29") + if !ok || r.H == 0 || r.Y+r.H > 300 { + t.Fatalf("last item outside the window: %+v", r) + } +} + +func TestMenubarAnnouncesHighlight(t *testing.T) { + a := &menubarApp{wrap: true} + tt := ui.NewTester(a.view, 700, 500) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) // View opens on Word wrap + tt.Key(0, ui.KeyDown) // Fit + got := tt.Announcements() + if len(got) < 2 || got[len(got)-2] != "Word wrap, checked" || got[len(got)-1] != "Fit" { + t.Fatalf("announcements %q", got) + } +} + func TestMenubarShortcutText(t *testing.T) { got := menubarShortcutText(MenubarShortcut{ui.Ctrl | ui.Shift, ui.KeyF5}) want := "Ctrl+Shift+F5" diff --git a/navigation_menu.go b/navigation_menu.go index da0d3cc..94783ad 100644 --- a/navigation_menu.go +++ b/navigation_menu.go @@ -101,19 +101,23 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s u := d.Unit() on := navigationMenuActive(l, path) b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius). - Label(l.Label).TextColor(k.TextMuted).Cursor(ui.CursorPointer) + Label(l.Label).Cursor(ui.CursorPointer) open := ui.Local(b, "open", func() bool { return false }) if l.Items == nil { b.Role(ui.RoleLink) } else { b.Role(ui.RoleMenuButton) } + fg := k.TextMuted if on { - b.TextColor(k.Text).FontWeight(600) + b.FontWeight(600) + fg = k.Text } if b.Hovered() || *open { - b.Background(k.SurfaceHover).TextColor(k.Text) + b.Background(k.SurfaceHover) + fg = k.Text } + internal.Ease(b, Motion(c, theme.StateDuration), fg) b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { if on { p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1) @@ -154,7 +158,7 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s panic("mujica: NavigationLink needs a Label") } if it.Items != nil { - ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(theme.CaptionSize).TextColor(k.Ornament.Mix(k.Text, 0.35)). + 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 @@ -191,6 +195,7 @@ func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Ele case e.Hovered(): e.Background(k.SurfaceHover) } + internal.Ease(e, Motion(c, theme.StateDuration), k.Text) e.Children(func() { if l.Icon != nil { ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0) @@ -198,7 +203,7 @@ func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Ele ui.Column(c).Gap(2).MinWidth(0).Children(func() { ui.Text(c, l.Label).SingleLine() if l.Description != "" { - ui.Text(c, l.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).FontWeight(400) + ui.Text(c, l.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontWeight(400) } }) }) diff --git a/navigation_review_test.go b/navigation_review_test.go new file mode 100644 index 0000000..ff29221 --- /dev/null +++ b/navigation_review_test.go @@ -0,0 +1,50 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale. +func TestNavigationCaptionsFollowTextScale(t *testing.T) { + for _, tc := range []struct { + text string + view func(c *ui.Context) + }{ + {"Name and seal", func(c *ui.Context) { + cur := 0 + Steps(c, &cur, []Step{{Title: "Account", Description: "Name and seal"}}, StepsOptions{}) + }}, + {"12", func(c *ui.Context) { + sel := "a" + Sidebar(c, &sel, []SidebarSection{{Title: "Mail", Items: []SidebarItem{{ID: "a", Label: "Inbox", Icon: icons.Must("inbox"), Count: 12}}}}, SidebarOptions{}) + }}, + {"Verse of the court", func(c *ui.Context) { + navigationMenuItem(c, NavigationLink{Label: "Poems", Path: "/p", Description: "Verse of the court"}, false, false) + }}, + {"Esc close", func(c *ui.Context) { + open := true + CommandPalette(c, &open, []Command{{ID: "a", Label: "A"}}, CommandPaletteOptions{}) + }}, + } { + height := func(scale float32) float32 { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Fill().AlignItems(ui.Start).Children(func() { tc.view(c) }) + }, 900, 700) + tt.SetPreferences(ui.Preferences{TextScale: scale}) + tt.Frame() + r, ok := tt.Find(tc.text) + if !ok { + t.Fatalf("no %q", tc.text) + } + return r.H + } + if h1, h2 := height(1), height(2); h2 < h1*1.8 { + t.Errorf("%q: height %v at 2x text, %v at 1x", tc.text, h2, h1) + } + } +} diff --git a/pagination.go b/pagination.go index 05a6fa3..f025bd3 100644 --- a/pagination.go +++ b/pagination.go @@ -141,16 +141,19 @@ func paginationButton(c *ui.Context, text string, icon *ui.SVG, name string, on, b := ui.ButtonBase(c).MinHeight(h).MinWidth(h).Padding(0, Density(c).Unit()*2).Radius(theme.ControlRadius). Label(name).Disabled(disabled) internal.Ring(b, k.Focus, theme.ControlRadius) + fg := k.Text switch { case on: - b.Background(k.Accent).TextColor(k.OnAccent).FontWeight(600) + b.Background(k.Accent).Border(theme.BorderWidth, k.Accent).FontWeight(600) + fg = k.OnAccent case b.Pressed(): - b.Background(k.Selection).TextColor(k.Text) + b.Background(k.Selection).Border(theme.BorderWidth, k.ControlBorder) case b.Hovered(): - b.Background(k.SurfaceHover).TextColor(k.Text).Border(theme.BorderWidth, k.ControlBorder) + b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder) default: - b.TextColor(k.Text).Border(theme.BorderWidth, k.Border) + b.Border(theme.BorderWidth, k.Border) } + internal.Ease(b, Motion(c, theme.StateDuration), fg) if icon != nil { b.Tooltip(name) } @@ -176,6 +179,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin if sel.Trigger.Hovered() { sel.Trigger.Background(k.SurfaceHover) } + internal.Ease(sel.Trigger, Motion(c, theme.StateDuration), k.Text) sel.Trigger.Children(func() { ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), *pageSize)).SingleLine() ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted) @@ -188,6 +192,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin if it.Highlighted() { it.Background(k.Selection) } + internal.Ease(it, Motion(c, theme.StateDuration), k.Text) it.Children(func() { ui.Text(c, fmt.Sprintf(msg(c, "pagination.per_page"), s)).SingleLine().Grow(1) if s == *pageSize { @@ -239,7 +244,7 @@ func paginationJump(c *ui.Context, pages int, set func(int)) { } else if in.Focused() { border = k.Focus } - box.Border(theme.BorderWidth, border) + internal.Ease(box.Border(theme.BorderWidth, border), Motion(c, theme.StateDuration), k.Text) if *bad { ui.Text(c, fmt.Sprintf(msg(c, "pagination.range"), pages)).TextColor(k.Danger).SingleLine() } diff --git a/segmented_control.go b/segmented_control.go index f20cfe9..f3b0b50 100644 --- a/segmented_control.go +++ b/segmented_control.go @@ -75,7 +75,7 @@ func SegmentedControl(c *ui.Context, selected *int, segments []Segment, opts Seg seg.Background(k.SurfaceHover) fg = k.Text } - seg.TextColor(fg) + internal.Ease(seg, Motion(c, theme.StateDuration), fg) internal.Ring(seg, k.Focus, theme.ControlRadius) if s.HideLabel { seg.Tooltip(s.Label) diff --git a/sidebar.go b/sidebar.go index 339400e..fa0398d 100644 --- a/sidebar.go +++ b/sidebar.go @@ -6,6 +6,8 @@ import ( "github.com/egoist/mygo/ui" + "mujicaui/icons" + "mujicaui/internal" "mujicaui/theme" ) @@ -76,8 +78,9 @@ func Sidebar(c *ui.Context, selected *string, sections []SidebarSection, opts Si if s.Title == "" { panic("mujica: SidebarSection needs a Title") } - ui.SidebarSection(c, s.Title, sidebarOpen(*closed, "s:"+s.Title), func() { - sidebarItems(c, *selected, s.Items, *closed, 0) + path := s.Title + sidebarGroup(c, s.Title, sidebarOpen(*closed, path), true, 0, func() { + sidebarItems(c, *selected, s.Items, *closed, path, 0) }) } }).FillHeight().FillWidth() @@ -104,17 +107,17 @@ func sidebarOpen(closed map[string]*bool, key string) *bool { return o } -func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, depth int) { +func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed map[string]*bool, parent string, depth int) { u := Density(c).Unit() for _, it := range items { if it.Label == "" { panic("mujica: SidebarItem needs a Label") } if it.Items != nil { - ui.Column(c).Padding(0, 0, 0, float32(depth)*u*4).Children(func() { - ui.SidebarSection(c, it.Label, sidebarOpen(closed, fmt.Sprintf("g%d:%s", depth, it.Label)), func() { - sidebarItems(c, selected, it.Items, closed, depth+1) - }) + // The key holds the whole path, so same-named groups stay apart. + path := parent + "\x00" + it.Label + sidebarGroup(c, it.Label, sidebarOpen(closed, path), false, depth, func() { + sidebarItems(c, selected, it.Items, closed, path, depth+1) }) continue } @@ -124,6 +127,50 @@ func sidebarItems(c *ui.Context, selected string, items []SidebarItem, closed ma } } +// sidebarGroup is a section title or nested group that Enter, Space or a click opens and closes. +func sidebarGroup(c *ui.Context, title string, open *bool, section bool, depth int, fn func()) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + ui.Column(c).Gap(u*0.5).Shrink(0).Padding(0, 0, 0, float32(depth)*u*4).Children(func() { + was := *open + tr := ui.CollapsibleBase(c, open).Trigger + if *open != was { + slog.Debug("mujica: sidebar group toggle", "group", title, "open", *open) + } + tr.AlignItems(ui.Center).Gap(u*2).Padding(u, u*2).Radius(theme.ControlRadius).Label(title).Cursor(ui.CursorPointer) + if section { + tr.Margin(u*2, 0, 0, 0).FontWeight(600).FontSize(FontSize(c, theme.CaptionSize)) + } else { + tr.MinHeight(d.ControlHeight()) + } + internal.Ring(tr, k.Focus, theme.ControlRadius) + if tr.Hovered() { + tr.Background(k.SurfaceHover) + } + fg := k.Text + if section { + fg = k.TextMuted + } + internal.Ease(tr, Motion(c, theme.StateDuration), fg) + turn := tr.Animate("mujica.turn", sidebarTurn(*open), Motion(c, theme.StateDuration)) + tr.Children(func() { + ui.Icon(c, icons.Must("chevron-right")).Size(12, 12).Shrink(0).Rotate(turn).TextColor(k.TextMuted) + ui.Text(c, title).SingleLine().Grow(1) + }) + if *open { + fn() + } + }) +} + +func sidebarTurn(open bool) float32 { + if open { + return 90 + } + return 0 +} + // sidebarFlat lists every leaf for the compact rail. func sidebarFlat(c *ui.Context, selected string, items []SidebarItem) { for _, it := range items { @@ -158,12 +205,13 @@ func sidebarItem(c *ui.Context, selected string, it SidebarItem, compact bool) { e.Tooltip(name).Label(name) } else if it.Count > 0 { e.Label(name).Children(func() { - ui.Text(c, sidebarCount(it.Count)).FontSize(theme.CaptionSize).FontFeatures("tnum"). + ui.Text(c, sidebarCount(it.Count)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum"). TextColor(k.TextMuted).Padding(0, d.Unit()*1.5).Radius(theme.ControlRadius).Background(k.Background) }) } + internal.Ease(e, Motion(c, theme.StateDuration), k.Text) if on { - e.Background(k.Selection).TextColor(k.Text).FontWeight(600) + e.Background(k.Selection).FontWeight(600) e.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5) }) diff --git a/sidebar_test.go b/sidebar_test.go index 9eb9f63..0ae2c17 100644 --- a/sidebar_test.go +++ b/sidebar_test.go @@ -124,6 +124,44 @@ func TestSidebarInvalid(t *testing.T) { } } +func TestSidebarGroupKeyboard(t *testing.T) { + sel := "inbox" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Sidebar(c, &sel, sidebarSample(), SidebarOptions{}) + }, 400, 500) + // Tab stops: the item list, the Library title, the Projects group. + for range 3 { + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyEnter) + if tt.HasText("Opera") { + t.Fatal("Enter did not collapse the group") + } + tt.Key(0, ui.KeySpace) + if !tt.HasText("Opera") { + t.Fatal("Space did not expand the group") + } +} + +func TestSidebarSameNamedGroups(t *testing.T) { + sel := "a1" + sections := []SidebarSection{ + {Title: "Court", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "a1", Label: "Edict"}}}}}, + {Title: "Chapel", Items: []SidebarItem{{Label: "Drafts", Items: []SidebarItem{{ID: "b1", Label: "Hymn"}}}}}, + } + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Sidebar(c, &sel, sections, SidebarOptions{}) + }, 400, 500) + if err := tt.Click("Drafts"); err != nil { + t.Fatal(err) + } + if tt.HasText("Edict") || !tt.HasText("Hymn") { + t.Fatalf("same-named groups share state: %q", tt.Texts()) + } +} + func TestSidebarSnapshot(t *testing.T) { snapshot(t, "sidebar", 420, 360, func(c *ui.Context) { a, b := "opera", "starred" diff --git a/steps.go b/steps.go index 8971b9a..cae4dde 100644 --- a/steps.go +++ b/steps.go @@ -101,6 +101,7 @@ func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, d if e.Hovered() { e.Background(k.SurfaceHover) } + internal.Ease(e, Motion(c, theme.StateDuration), k.Text) } else { e = ui.Row(c).Shrink(0).Role(ui.RoleListItem) } @@ -130,7 +131,7 @@ func stepsItem(c *ui.Context, i, n int, s Step, st stepsState, vertical, tail, d title.TextColor(k.TextMuted) } if s.Description != "" { - ui.Text(c, s.Description).FontSize(theme.CaptionSize).TextColor(k.TextMuted).MaxWidth(240) + ui.Text(c, s.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).MaxWidth(240) } }) }) @@ -161,7 +162,7 @@ func stepsMarker(c *ui.Context, i int, st stepsState) { if icon != "" { ui.Icon(c, icons.Must(icon)).Size(14, 14) } else { - ui.Text(c, strconv.Itoa(i+1)).FontSize(theme.CaptionSize).SingleLine() + ui.Text(c, strconv.Itoa(i+1)).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() } }) } diff --git a/tabs.go b/tabs.go index c3b022e..36afb61 100644 --- a/tabs.go +++ b/tabs.go @@ -152,7 +152,7 @@ func tabsStyle(c *ui.Context, tab *ui.Element, t Tab, on, vertical bool) { tab.Background(k.SurfaceHover) fg = k.Text } - tab.TextColor(fg) + internal.Ease(tab, Motion(c, theme.StateDuration), fg) tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) { if on { if vertical { diff --git a/tabs_test.go b/tabs_test.go index f5ba7f2..d395ae2 100644 --- a/tabs_test.go +++ b/tabs_test.go @@ -1,8 +1,14 @@ package mujica import ( + "bytes" + "image/color" + "os" + "path/filepath" + "runtime" "strings" "testing" + "time" "github.com/egoist/mygo/ui" @@ -153,6 +159,77 @@ func TestTabsSnapshot(t *testing.T) { } } +// tabsStateSnapshot compares tabsView after act in both themes with -.png. +func tabsStateSnapshot(t *testing.T, name string, act func(tt *ui.Tester)) { + t.Helper() + for _, m := range []Mode{Light, Dark} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: m}) + tabsView(c) + }, 520, 300) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + act(tt) + tt.Frame() + got := encode(t, tt.Image()) + path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png") + if *update { + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + continue + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run go test -update)", err) + } + if !bytes.Equal(got, want) { + if err := os.WriteFile(path+".got.png", got, 0o644); err != nil { + t.Fatal(err) + } + t.Errorf("%s differs; see %s.got.png", path, path) + } + } +} + +func TestTabsStateSnapshots(t *testing.T) { + center := func(tt *ui.Tester, s string) (float32, float32) { + r, ok := tt.Find(s) + if !ok { + t.Fatalf("no %q", s) + } + return r.X + r.W/2, r.Y + r.H/2 + } + tabsStateSnapshot(t, "tabs-hover", func(tt *ui.Tester) { tt.Move(center(tt, "Members")) }) + tabsStateSnapshot(t, "tabs-pressed", func(tt *ui.Tester) { tt.Press(center(tt, "Settings")) }) + tabsStateSnapshot(t, "tabs-focus", func(tt *ui.Tester) { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + }) +} + +// TestTabsStateColorsEase checks hover fades in over StateDuration, not at once. +func TestTabsStateColorsEase(t *testing.T) { + sel := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + Tabs(c, &sel, []Tab{{Label: "One"}, {Label: "Two"}}, TabsOptions{}) + }, 300, 120) + r, _ := tt.Find("Two") + x, y := int(r.X-8), int(r.Y+r.H/2) + hover := tabsRGBA(theme.Light().SurfaceHover) + tt.Move(r.X+r.W/2, r.Y+r.H/2) + if got := tt.Image().RGBAAt(x, y); got == hover { + t.Fatal("hover color applied at once") + } + time.Sleep(theme.StateDuration + 40*time.Millisecond) + tt.Frame() + if got := tt.Image().RGBAAt(x, y); got != hover { + t.Fatalf("hover color %v after the transition, want %v", got, hover) + } +} + +func tabsRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} } + // tabsMode recovers the mode snapshot applied, to add a density. func tabsMode(c *ui.Context) Mode { if Tokens(c).Dark { diff --git a/testdata/snapshots/darwin/pagination-dark.png b/testdata/snapshots/darwin/pagination-dark.png index 25c2935..85cd150 100644 Binary files a/testdata/snapshots/darwin/pagination-dark.png and b/testdata/snapshots/darwin/pagination-dark.png differ diff --git a/testdata/snapshots/darwin/pagination-light.png b/testdata/snapshots/darwin/pagination-light.png index e8f1911..12bdbf2 100644 Binary files a/testdata/snapshots/darwin/pagination-light.png and b/testdata/snapshots/darwin/pagination-light.png differ diff --git a/testdata/snapshots/darwin/sidebar-dark.png b/testdata/snapshots/darwin/sidebar-dark.png index df7abfc..378dc07 100644 Binary files a/testdata/snapshots/darwin/sidebar-dark.png and b/testdata/snapshots/darwin/sidebar-dark.png differ diff --git a/testdata/snapshots/darwin/sidebar-light.png b/testdata/snapshots/darwin/sidebar-light.png index 3cd875d..d7c77de 100644 Binary files a/testdata/snapshots/darwin/sidebar-light.png and b/testdata/snapshots/darwin/sidebar-light.png differ diff --git a/testdata/snapshots/darwin/tabs-focus-dark.png b/testdata/snapshots/darwin/tabs-focus-dark.png new file mode 100644 index 0000000..345c836 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-focus-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-focus-light.png b/testdata/snapshots/darwin/tabs-focus-light.png new file mode 100644 index 0000000..3d4a5d9 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-focus-light.png differ diff --git a/testdata/snapshots/darwin/tabs-hover-dark.png b/testdata/snapshots/darwin/tabs-hover-dark.png new file mode 100644 index 0000000..7d6f8e9 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-hover-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-hover-light.png b/testdata/snapshots/darwin/tabs-hover-light.png new file mode 100644 index 0000000..4ab1e62 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-hover-light.png differ diff --git a/testdata/snapshots/darwin/tabs-pressed-dark.png b/testdata/snapshots/darwin/tabs-pressed-dark.png new file mode 100644 index 0000000..90652e8 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-pressed-dark.png differ diff --git a/testdata/snapshots/darwin/tabs-pressed-light.png b/testdata/snapshots/darwin/tabs-pressed-light.png new file mode 100644 index 0000000..382b499 Binary files /dev/null and b/testdata/snapshots/darwin/tabs-pressed-light.png differ diff --git a/toolbar.go b/toolbar.go index 712fefd..2982c3f 100644 --- a/toolbar.go +++ b/toolbar.go @@ -57,6 +57,7 @@ func ToolbarButton(c *ui.Context, label string, opts ToolbarButtonOptions) *ui.E case b.Hovered(): b.Background(k.SurfaceHover) } + internal.Ease(b, Motion(c, theme.StateDuration), k.Text) b.Children(func() { if opts.Icon != nil { ui.Icon(c, opts.Icon).Size(16, 16)