diff --git a/alert_dialog.go b/alert_dialog.go index e3268a2..c8ffb9b 100644 --- a/alert_dialog.go +++ b/alert_dialog.go @@ -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) } diff --git a/dialog.go b/dialog.go index 4aa7ed2..dab5c77 100644 --- a/dialog.go +++ b/dialog.go @@ -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 } diff --git a/docs/components/081-dialog.md b/docs/components/081-dialog.md index 73afded..bbb63dd 100644 --- a/docs/components/081-dialog.md +++ b/docs/components/081-dialog.md @@ -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% 文字缩放)。 ## 状态 diff --git a/docs/components/087-dropdown-menu.md b/docs/components/087-dropdown-menu.md index 6eb6bad..d47d21f 100644 --- a/docs/components/087-dropdown-menu.md +++ b/docs/components/087-dropdown-menu.md @@ -53,7 +53,7 @@ ui.Render(view, 400, 300, 1) - 触发按钮:次按钮外观加下拉箭头,打开时保持按下底色。 - 菜单项:悬停或键盘焦点时显示 Selection 底色和左侧 2 DIP 强调条;勾选项显示勾号;禁用项变暗。 -- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,下方放不下时上移。 +- 菜单面板最高为可用空间,超出时滚动;子菜单在右侧打开,右侧放不下时翻到左侧,仍放不下时贴窗口边缘,宽度不超过窗口;下方放不下时上移。 ## 事件 diff --git a/docs/components/089-popconfirm.md b/docs/components/089-popconfirm.md index caea581..fbedf74 100644 --- a/docs/components/089-popconfirm.md +++ b/docs/components/089-popconfirm.md @@ -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 | 取消并把焦点还给锚点。 | diff --git a/docs/components/090-overlay.md b/docs/components/090-overlay.md index 3aac2c8..4b20709 100644 --- a/docs/components/090-overlay.md +++ b/docs/components/090-overlay.md @@ -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`,内容超出时滚动。 ## 事件 diff --git a/drawer.go b/drawer.go index dee05e8..9c76ede 100644 --- a/drawer.go +++ b/drawer.go @@ -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) } diff --git a/dropdown_menu_test.go b/dropdown_menu_test.go index 394ef59..d06ed43 100644 --- a/dropdown_menu_test.go +++ b/dropdown_menu_test.go @@ -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) { diff --git a/examples/gallery/pages_overlay.go b/examples/gallery/pages_overlay.go index e4e650c..76a48ae 100644 --- a/examples/gallery/pages_overlay.go +++ b/examples/gallery/pages_overlay.go @@ -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 关闭;模态时焦点限制在覆盖层内,点击遮罩关闭。"}, } diff --git a/overlay.go b/overlay.go index e328e9b..c2448e2 100644 --- a/overlay.go +++ b/overlay.go @@ -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) { diff --git a/overlay_fit_test.go b/overlay_fit_test.go new file mode 100644 index 0000000..a64ea50 --- /dev/null +++ b/overlay_fit_test.go @@ -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) + } + } +} diff --git a/popconfirm.go b/popconfirm.go index f4bc647..f12300d 100644 --- a/popconfirm.go +++ b/popconfirm.go @@ -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 } }) diff --git a/popconfirm_test.go b/popconfirm_test.go index b939a45..1b240ec 100644 --- a/popconfirm_test.go +++ b/popconfirm_test.go @@ -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) diff --git a/popup_menu.go b/popup_menu.go index d0d272a..184d6bd 100644 --- a/popup_menu.go +++ b/popup_menu.go @@ -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 { diff --git a/testdata/snapshots/darwin/alert_dialog-dark.png b/testdata/snapshots/darwin/alert_dialog-dark.png index 48cc493..cd471ae 100644 Binary files a/testdata/snapshots/darwin/alert_dialog-dark.png and b/testdata/snapshots/darwin/alert_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/alert_dialog-light.png b/testdata/snapshots/darwin/alert_dialog-light.png index b108d13..12df12c 100644 Binary files a/testdata/snapshots/darwin/alert_dialog-light.png and b/testdata/snapshots/darwin/alert_dialog-light.png differ diff --git a/testdata/snapshots/darwin/dialog-comfortable-dark.png b/testdata/snapshots/darwin/dialog-comfortable-dark.png index 81d0edd..860367f 100644 Binary files a/testdata/snapshots/darwin/dialog-comfortable-dark.png and b/testdata/snapshots/darwin/dialog-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-comfortable-light.png b/testdata/snapshots/darwin/dialog-comfortable-light.png index 958125c..d00b726 100644 Binary files a/testdata/snapshots/darwin/dialog-comfortable-light.png and b/testdata/snapshots/darwin/dialog-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/dialog-compact-dark.png b/testdata/snapshots/darwin/dialog-compact-dark.png index 64d6e33..7b03b78 100644 Binary files a/testdata/snapshots/darwin/dialog-compact-dark.png and b/testdata/snapshots/darwin/dialog-compact-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-compact-light.png b/testdata/snapshots/darwin/dialog-compact-light.png index 7b8f989..81f3247 100644 Binary files a/testdata/snapshots/darwin/dialog-compact-light.png and b/testdata/snapshots/darwin/dialog-compact-light.png differ diff --git a/testdata/snapshots/darwin/dialog-dark.png b/testdata/snapshots/darwin/dialog-dark.png index ef9ee76..2c22caf 100644 Binary files a/testdata/snapshots/darwin/dialog-dark.png and b/testdata/snapshots/darwin/dialog-dark.png differ diff --git a/testdata/snapshots/darwin/dialog-light.png b/testdata/snapshots/darwin/dialog-light.png index 9e7901f..7c8c7ee 100644 Binary files a/testdata/snapshots/darwin/dialog-light.png and b/testdata/snapshots/darwin/dialog-light.png differ