Navigation: review r1 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:43:01 -04:00
1 parent a27727bc71
commit 03a3aeaa1f
31 files changed
+386 -44

No files matched your search

+10 -4
View File
@@ -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)
+13 -2
View File
@@ -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 {
+11
View File
@@ -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)
+1 -1
View File
@@ -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 统一降低不透明度,不响应指针与键盘。
- 溢出:标签条横向(纵向时竖向)滚动;键盘或点击切换后,选中标签滚入视野。
+2 -2
View File
@@ -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`)。组标题本身不能被选中。展开状态按完整父路径保存,同名组互不影响。
+2 -2
View File
@@ -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`。
+1
View File
@@ -63,3 +63,4 @@ if id, ok := res.Chosen(); ok {
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
- 搜索是子串匹配,不做模糊匹配或排序打分。
- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
+19
View File
@@ -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)})
}
+27 -1
View File
@@ -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)
}
+19 -9
View File
@@ -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",
+33
View File
@@ -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"
+10 -5
View File
@@ -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)
}
})
})
+50
View File
@@ -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)
}
}
}
+10 -5
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+57 -9
View File
@@ -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)
})
+38
View File
@@ -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"
+3 -2
View File
@@ -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()
}
})
}
+1 -1
View File
@@ -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 {
+77
View File
@@ -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 <name>-<mode>.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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -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)