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