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