Merge branch 'worktree-agent-ae66cd6d621c16060'
No files matched your search
@@ -62,13 +62,14 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog
|
||||
panel.Role(ui.RoleAlertDialog).Label(opts.Title).Description(opts.Description)
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(22, 22).TextColor(k.Danger).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u * 2).Grow(1).Children(func() {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
})
|
||||
if opts.Description != "" {
|
||||
// A long description scrolls so the field and the actions stay in the window.
|
||||
ui.Scroll(c).Shrink(1).Padding(0, 0, 0, 22+u*3).Children(func() {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
})
|
||||
}
|
||||
if opts.ConfirmText != "" {
|
||||
alertDialogTyped(c, st, opts.ConfirmText)
|
||||
}
|
||||
|
||||
@@ -50,10 +50,16 @@ func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.E
|
||||
if opts.Description != "" {
|
||||
panel.Description(opts.Description)
|
||||
}
|
||||
closed = dialogHeader(c, opts.Title, opts.Description, opts.Decorated, !opts.HideClose, "dialog.close")
|
||||
if content != nil {
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
}
|
||||
closed = dialogHeader(c, opts.Title, opts.Decorated, !opts.HideClose, "dialog.close")
|
||||
// Description and content scroll together so the actions always keep their room.
|
||||
ui.Scroll(c).Shrink(1).Gap(Density(c).Unit() * 3).Children(func() {
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
if content != nil {
|
||||
content()
|
||||
}
|
||||
})
|
||||
if opts.Actions != nil {
|
||||
dialogActions(c, opts.Actions)
|
||||
}
|
||||
@@ -102,8 +108,8 @@ func dialogOrnaments(p *ui.Painter, r ui.Rect, ink, ground ui.Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// dialogHeader builds the title, description and close button; it reports a close click.
|
||||
func dialogHeader(c *ui.Context, title, description string, decorated, closable bool, closeKey string) bool {
|
||||
// dialogHeader builds the title and close button; it reports a close click.
|
||||
func dialogHeader(c *ui.Context, title string, decorated, closable bool, closeKey string) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
clicked := false
|
||||
@@ -117,9 +123,6 @@ func dialogHeader(c *ui.Context, title, description string, decorated, closable
|
||||
if decorated {
|
||||
dialogRule(c, k.Ornament)
|
||||
}
|
||||
if description != "" {
|
||||
ui.Text(c, description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
return clicked
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ ui.Render(view, 400, 300, 1)
|
||||
| `KeepOnEscape` | `bool` | `false` | Escape 不关闭。 |
|
||||
| `HideClose` | `bool` | `false` | 不显示右上角关闭按钮。 |
|
||||
|
||||
`open` 为 false 时不构建任何元素。`content` 可为 nil;内容超出窗口高度时在内容区滚动。
|
||||
`open` 为 false 时不构建任何元素。`content` 可为 nil;说明与内容一起滚动,标题与操作区始终留在窗口内(含 200% 文字缩放)。
|
||||
|
||||
## 状态
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
- 触发按钮:次按钮外观加下拉箭头,打开时保持按下底色。
|
||||
- 菜单项:悬停或键盘焦点时显示 Selection 底色和左侧 2 DIP 强调条;勾选项显示勾号;禁用项变暗。
|
||||
- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,下方放不下时上移。
|
||||
- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,仍放不下时贴窗口边缘,宽度不超过窗口;下方放不下时上移。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ ui.Render(view, 400, 300, 1)
|
||||
| `Description` | `string` | `""` | 补充说明,作为面板的无障碍描述。 |
|
||||
| `ConfirmLabel` | `string` | 内置“确定 / Confirm” | 确认按钮文字。 |
|
||||
| `CancelLabel` | `string` | 内置“取消 / Cancel” | 取消按钮文字。 |
|
||||
| `Danger` | `bool` | `false` | 确认按钮使用危险变体,图标改为 Danger 色。 |
|
||||
| `Danger` | `bool` | `false` | 确认按钮使用危险变体,图标改为 Danger 色,打开时焦点改在取消。 |
|
||||
|
||||
`PopconfirmResult{Confirmed, Cancelled bool}` 只在发生选择的那一帧为 true。
|
||||
|
||||
@@ -48,7 +48,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| 打开时 | 焦点在确认按钮。 |
|
||||
| 打开时 | 焦点在确认按钮;`Danger` 时在取消按钮,连按 Enter 不会执行危险操作。 |
|
||||
| Enter | 触发聚焦的按钮。 |
|
||||
| Tab | 在取消与确认间移动,焦点顺序紧跟锚点。 |
|
||||
| Escape | 取消并把焦点还给锚点。 |
|
||||
|
||||
@@ -31,7 +31,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
弹层外观(Surface 底、1 DIP 边框、6 DIP 圆角)与 160 ms 淡入;减少动画时直接显示。
|
||||
弹层外观(Surface 底、1 DIP 边框、6 DIP 圆角)与 160 ms 淡入;减少动画时直接显示。面板不超过窗口减去 `Inset`,内容超出时滚动。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E
|
||||
body := func(panel *ui.Element, p float32) {
|
||||
drawerPanel(c, panel, opts.Side, size, p)
|
||||
panel.Label(opts.Title)
|
||||
closed = dialogHeader(c, opts.Title, "", false, true, "drawer.close")
|
||||
closed = dialogHeader(c, opts.Title, false, true, "drawer.close")
|
||||
if content != nil {
|
||||
ui.Scroll(c).Grow(1).Shrink(1).Children(content)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type menuApp struct {
|
||||
chosen []string
|
||||
pinned bool
|
||||
locked bool
|
||||
chosen []string
|
||||
pinned bool
|
||||
locked bool
|
||||
pinDisabled bool
|
||||
}
|
||||
|
||||
func (a *menuApp) build(m *PopupMenu) {
|
||||
@@ -17,7 +19,7 @@ func (a *menuApp) build(m *PopupMenu) {
|
||||
if m.Item("Rename", PopupMenuItemOptions{}) {
|
||||
a.chosen = append(a.chosen, "Rename")
|
||||
}
|
||||
if m.Check("Pinned", &a.pinned, PopupMenuItemOptions{KeepOpen: true}) {
|
||||
if m.Check("Pinned", &a.pinned, PopupMenuItemOptions{KeepOpen: true, Disabled: a.pinDisabled}) {
|
||||
a.chosen = append(a.chosen, "Pinned")
|
||||
}
|
||||
})
|
||||
@@ -133,6 +135,49 @@ func TestDropdownMenuSubmenuFlips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuCheckDisabledThisFrameKeepsValue(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
_ = tt.Click("Actions")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Pinned") {
|
||||
t.Fatal("Pinned not focused")
|
||||
}
|
||||
a.pinDisabled = true // takes effect in the frame that handles the key
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if a.pinned || len(a.chosen) != 0 {
|
||||
t.Fatalf("disabled check flipped: pinned %v chosen %q", a.pinned, a.chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuWideSubmenuStaysInWindow(t *testing.T) {
|
||||
wide := strings.Repeat("Wide ", 30)
|
||||
var sub ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Fill().Padding(20, 0, 0, 200).Children(func() {
|
||||
DropdownMenu(c, "Actions", DropdownMenuOptions{}, func(m *PopupMenu) {
|
||||
m.Item(wide, PopupMenuItemOptions{})
|
||||
m.Submenu("Move to", PopupMenuItemOptions{}, func(m *PopupMenu) {
|
||||
m.Item(wide+"archive", PopupMenuItemOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}, 800, 400)
|
||||
_ = tt.Click("Actions")
|
||||
r, _ := tt.Find("Move to")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
tt.Frame()
|
||||
s, ok := tt.Find(wide + "archive")
|
||||
if !ok {
|
||||
t.Fatal("submenu did not open")
|
||||
}
|
||||
sub = s
|
||||
if sub.X < 4 || sub.X+sub.W > 796 {
|
||||
t.Fatalf("submenu item %+v outside the 800 DIP window", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuDisabled(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
|
||||
@@ -29,7 +29,7 @@ var overlayPages = []page{
|
||||
{ID: "088", Name: "ContextMenu", ZhName: "右键菜单", Category: overlayCategory, Demo: contextMenuDemo, Code: contextMenuCode,
|
||||
Keys: "右键在指针处打开;焦点在区域内时按 Menu 键或 Shift+F10 在区域下方打开;其余按键同下拉菜单。"},
|
||||
{ID: "089", Name: "Popconfirm", ZhName: "气泡确认", Category: overlayCategory, Demo: popconfirmDemo, Code: popconfirmCode,
|
||||
Keys: "打开后焦点在确认;Enter 确认;Escape 或外部点击取消。"},
|
||||
Keys: "打开后焦点在确认(Danger 时在取消);Enter 触发聚焦的按钮;Escape 或外部点击取消。"},
|
||||
{ID: "090", Name: "Overlay", ZhName: "覆盖层", Category: overlayCategory, Demo: overlayDemo, Code: overlayCode,
|
||||
Keys: "Escape 关闭;模态时焦点限制在覆盖层内,点击遮罩关闭。"},
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
back.Background(overlayScrim(k).Alpha(p))
|
||||
}
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Label(opts.Label).Padding(Density(c).Unit() * 4)
|
||||
content()
|
||||
w, h := c.Size()
|
||||
panel.Label(opts.Label).Padding(Density(c).Unit() * 4).MaxWidth(w - 2*inset).MaxHeight(h - 2*inset)
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
})
|
||||
focus.shown(panel)
|
||||
if !shown {
|
||||
@@ -81,7 +82,8 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
var panel *ui.Element
|
||||
ui.Overlay(c, func() {
|
||||
w, h := c.Size()
|
||||
panel = ui.Column(c).Absolute().Role(ui.RoleDialog).Label(opts.Label).Padding(Density(c).Unit() * 4)
|
||||
panel = ui.Column(c).Absolute().Role(ui.RoleDialog).Label(opts.Label).Padding(Density(c).Unit() * 4).
|
||||
MaxWidth(w - 2*inset).MaxHeight(h - 2*inset)
|
||||
b := panel.Bounds()
|
||||
panel.Left(inset + (w-2*inset-b.W)*fx).Top(inset + (h-2*inset-b.H)*fy)
|
||||
p := overlayEnter(c, panel)
|
||||
@@ -90,7 +92,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
c.AnimationFrame()
|
||||
}
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Children(content)
|
||||
panel.Children(func() { ui.Scroll(c).Shrink(1).Children(content) })
|
||||
})
|
||||
focus.shown(panel)
|
||||
if overlayEscape(c, open) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// overlayLongText is about 372 characters of description.
|
||||
var overlayLongText = strings.Repeat("The east wing stays sealed until the court returns. ", 7)
|
||||
|
||||
// overlayInWindow fails unless every label lies fully inside a w×h window.
|
||||
func overlayInWindow(t *testing.T, tt *ui.Tester, w, h float32, labels ...string) {
|
||||
t.Helper()
|
||||
for _, l := range labels {
|
||||
r, ok := tt.Find(l)
|
||||
if !ok || r.W == 0 || r.H == 0 || r.Y < 0 || r.X < 0 || r.Y+r.H > h || r.X+r.W > w {
|
||||
t.Errorf("%q at %+v (found %v) outside the %v×%v window", l, r, ok, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogActionsStayInWindowAtLargeText(t *testing.T) {
|
||||
open, name := true, "Ophelia"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{Title: "Rename", Description: overlayLongText, Actions: func() {
|
||||
Button(c, "Cancel", ButtonOptions{Variant: Secondary})
|
||||
Button(c, "Save", ButtonOptions{})
|
||||
}}, func() { ui.TextInput(c, &name) })
|
||||
}, 800, 600)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame()
|
||||
overlayInWindow(t, tt, 800, 600, "Cancel", "Save")
|
||||
}
|
||||
|
||||
func TestAlertDialogActionsStayInWindowAtLargeText(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AlertDialog(c, &open, AlertDialogOptions{Title: "Burn?", Description: overlayLongText, ConfirmText: "letters"})
|
||||
}, 800, 600)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame()
|
||||
overlayInWindow(t, tt, 800, 600, "Cancel", "Delete", "Type “letters” to confirm")
|
||||
}
|
||||
|
||||
func TestOverlayPanelFitsWindow(t *testing.T) {
|
||||
for _, modal := range []bool{true, false} {
|
||||
open := true
|
||||
var panel ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if p := Overlay(c, &open, OverlayOptions{Modal: modal}, func() {
|
||||
ui.Text(c, strings.Repeat(overlayLongText, 6))
|
||||
Button(c, "Done", ButtonOptions{})
|
||||
}); p != nil {
|
||||
panel = p.Bounds()
|
||||
}
|
||||
}, 600, 400)
|
||||
tt.Frame()
|
||||
if panel.X < 16 || panel.Y < 16 || panel.X+panel.W > 584 || panel.Y+panel.H > 384 {
|
||||
t.Errorf("modal %v: panel %+v outside the window minus Inset", modal, panel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,19 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp
|
||||
// The text scrolls so the actions stay in the window.
|
||||
ui.Scroll(c).Shrink(1).Children(func() { popconfirmText(c, opts) })
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Shrink(0).Children(func() {
|
||||
if Button(c, cancel, ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
// Danger focuses Cancel, so a second Enter never runs the destructive action.
|
||||
cb := Button(c, cancel, ButtonOptions{Variant: Secondary})
|
||||
if opts.Danger {
|
||||
cb.AutoFocus()
|
||||
}
|
||||
if cb.Clicked() {
|
||||
res.Cancelled = true
|
||||
}
|
||||
if Button(c, confirm, ButtonOptions{Variant: variant}).AutoFocus().Clicked() {
|
||||
ok := Button(c, confirm, ButtonOptions{Variant: variant})
|
||||
if !opts.Danger {
|
||||
ok.AutoFocus()
|
||||
}
|
||||
if ok.Clicked() {
|
||||
res.Confirmed = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -46,6 +46,29 @@ func TestPopconfirmConfirmByKeyboard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmDangerFocusesCancel(t *testing.T) {
|
||||
open, confirmed := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
b := Button(c, "Dismiss", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
open = true
|
||||
}
|
||||
if Popconfirm(c, b, &open, PopconfirmOptions{Title: "Dismiss the guard?", Danger: true}).Confirmed {
|
||||
confirmed++
|
||||
}
|
||||
}, 400, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !open || !tt.Focused("Cancel") {
|
||||
t.Fatalf("open %v, Cancel focused %v", open, tt.Focused("Cancel"))
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if confirmed != 0 || open {
|
||||
t.Fatalf("second Enter: confirmed %d open %v", confirmed, open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmCancelPaths(t *testing.T) {
|
||||
a := &popconfirmApp{}
|
||||
tt := ui.NewTester(a.view, 400, 300)
|
||||
|
||||
@@ -56,9 +56,17 @@ func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool {
|
||||
|
||||
// Check adds an item toggling *checked and reports whether it changed.
|
||||
func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions) bool {
|
||||
it := m.row(ui.CheckboxBase(m.c, checked), opts.Disabled, false)
|
||||
// The base toggles as it is built, so a disabled wrapper and a copy come first.
|
||||
value := checked
|
||||
if opts.Disabled {
|
||||
cp := *checked
|
||||
value = &cp
|
||||
}
|
||||
var base *ui.Element
|
||||
ui.Column(m.c).Shrink(0).Disabled(opts.Disabled).Children(func() { base = ui.CheckboxBase(m.c, value) })
|
||||
it := m.row(base, opts.Disabled, false)
|
||||
m.content(it, label, *checked, false)
|
||||
if !it.Changed() {
|
||||
if opts.Disabled || !it.Changed() {
|
||||
return false
|
||||
}
|
||||
slog.Debug("mujica: popup_menu check", "item", label, "checked", *checked)
|
||||
@@ -202,14 +210,16 @@ func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot,
|
||||
func popupMenuSubmenu(c *ui.Context, outer *ui.Element, root *popupMenuRoot, lv *popupMenuLevel, s popupMenuSub) {
|
||||
u := Density(c).Unit()
|
||||
ob, ib := outer.Bounds(), s.item.Bounds()
|
||||
box := ui.Column(c).Absolute().Key(s.seq).MinWidth(180).MaxWidth(360)
|
||||
sb := box.Bounds()
|
||||
ww, wh := c.Size()
|
||||
box := ui.Column(c).Absolute().Key(s.seq).MinWidth(min(180, ww-8)).MaxWidth(min(360, ww-8))
|
||||
sb := box.Bounds()
|
||||
left, top := ib.X+ib.W+u-ob.X, ib.Y-u-ob.Y
|
||||
if ob.X+left+sb.W > ww-4 {
|
||||
left = ib.X - u - sb.W - ob.X
|
||||
}
|
||||
top = min(top, wh-4-sb.H-ob.Y)
|
||||
// Flipped or not, the submenu stays inside the window.
|
||||
left = max(4-ob.X, min(left, ww-4-sb.W-ob.X))
|
||||
top = max(4-ob.Y, min(top, wh-4-sb.H-ob.Y))
|
||||
box.Left(left).Top(top).MaxHeight(wh - 8)
|
||||
p := overlayEnter(c, box)
|
||||
if sb.W == 0 || ob.W == 0 {
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |