diff --git a/alert_dialog.go b/alert_dialog.go index 1f619d1..e3268a2 100644 --- a/alert_dialog.go +++ b/alert_dialog.go @@ -31,17 +31,18 @@ type AlertDialogResult struct { // alertDialogState is the typed confirmation, kept while the alert shows. type alertDialogState struct{ typed string } -// AlertDialog asks to confirm a destructive action while *open is true. -// Cancel takes the focus, the backdrop ignores clicks, Escape cancels, -// and Enter never confirms before ConfirmText is typed. +// AlertDialog confirms a destructive action; Cancel has the focus. func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult { if opts.Title == "" { panic("mujica: AlertDialog needs a Title") } var res AlertDialogResult + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return res } + overlayLayer(c, open) k := Tokens(c) u := Density(c).Unit() confirmLabel, cancelLabel := opts.ConfirmLabel, opts.CancelLabel @@ -52,7 +53,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog cancelLabel = msg(c, "alert_dialog.cancel") } shown, backClicked := true, false - ui.DialogBase(c, &shown, func(back, panel *ui.Element) { + panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { backClicked = back.Clicked() st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} }) p := overlayEnter(c, panel) @@ -62,15 +63,16 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog 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(theme.H3Size).FontWeight(600) + 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) } }) }) - submitted := opts.ConfirmText != "" && alertDialogTyped(c, st, opts.ConfirmText) + if opts.ConfirmText != "" { + alertDialogTyped(c, st, opts.ConfirmText) + } unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText - res.Confirmed = submitted && unlocked dialogActions(c, func() { if Button(c, cancelLabel, ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { res.Cancelled = true @@ -80,6 +82,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog } }) }) + focus.shown(panel) if !shown && !backClicked { res.Cancelled = true // Escape } @@ -93,18 +96,15 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog return res } -// alertDialogTyped builds the confirmation field and reports Enter in it. -func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) bool { +// alertDialogTyped builds the confirmation field; Enter in it never confirms, even mid-composition. +func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) { k := Tokens(c) d := Density(c) prompt := fmt.Sprintf(msg(c, "alert_dialog.type_to_unlock"), want) - submitted := false ui.Column(c).Gap(d.Unit()).Shrink(0).Children(func() { - ui.Text(c, prompt).FontSize(theme.CaptionSize).TextColor(k.TextMuted) + ui.Text(c, prompt).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) in := ui.TextInputBase(c, &st.typed).Label(prompt).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3). Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) internal.Ring(in, k.Focus, theme.ControlRadius) - submitted = in.Submitted() }) - return submitted } diff --git a/alert_dialog_test.go b/alert_dialog_test.go index 0d23111..1a7651b 100644 --- a/alert_dialog_test.go +++ b/alert_dialog_test.go @@ -86,11 +86,33 @@ func TestAlertDialogConfirmTextGatesEnter(t *testing.T) { } tt.Type("e") tt.Key(0, ui.KeyEnter) + if a.confirmed != 0 || !a.open { + t.Fatalf("Enter in the field confirmed: %d", a.confirmed) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Delete") { + t.Fatal("Delete not reachable once unlocked") + } + tt.Key(0, ui.KeyEnter) if a.confirmed != 1 || a.open { t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open) } } +// MyGo commits a composition on Enter and then handles the key (ui/editor.go:688). +func TestAlertDialogEnterDuringCompositionDoesNotConfirm(t *testing.T) { + a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"}) + if err := tt.Click("Type “archive” to confirm"); err != nil { + t.Fatal(err) + } + tt.Compose("archive", 7) + tt.Key(0, ui.KeyEnter) + if a.confirmed != 0 || !a.open { + t.Fatalf("Enter while composing confirmed: %d", a.confirmed) + } +} + func TestAlertDialogSnapshot(t *testing.T) { snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) { open := true diff --git a/context_menu.go b/context_menu.go index 468029c..5644a06 100644 --- a/context_menu.go +++ b/context_menu.go @@ -17,11 +17,7 @@ type contextMenuState struct { x, y float32 } -// ContextMenu gives target an in-window menu that build fills. A secondary -// click opens it at the pointer; the menu key or Shift+F10, while the focus -// is in target, opens it below target. It flips to stay in the window, and -// the focus returns on close. target must be a container: the menu adds an -// anchor child to it, and takes its HandleInput. It returns target. +// ContextMenu gives the container target an in-window menu at the pointer or below the focus. func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element { if target == nil { panic("mujica: ContextMenu needs a target") diff --git a/context_menu_test.go b/context_menu_test.go index c14c3c1..d767599 100644 --- a/context_menu_test.go +++ b/context_menu_test.go @@ -92,14 +92,19 @@ func TestContextMenuDisabled(t *testing.T) { } func TestContextMenuSnapshot(t *testing.T) { - snapshot(t, "context_menu", 460, 320, func(c *ui.Context) { - a := &menuApp{} + a := &menuApp{} + overlaySnapshot(t, "context_menu", 460, 320, func(c *ui.Context) { ui.Column(c).Padding(20).Children(func() { row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection) row.Children(func() { ui.Text(c, "Letter to the Countess") }) - anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1) - open := true - popupMenuOpen(c, anchor, &open, a.build) + ContextMenu(c, row, ContextMenuOptions{}, a.build) }) + }, func(tt *ui.Tester) { + tt.RightClickAt(160, 40) + r, ok := tt.Find("Pinned") + if !ok { + t.Fatal("menu did not open") + } + tt.Move(r.X+4, r.Y+4) // hover highlight on Pinned }) } diff --git a/dialog.go b/dialog.go index 486e4f1..4aa7ed2 100644 --- a/dialog.go +++ b/dialog.go @@ -28,15 +28,17 @@ type DialogOptions struct { HideClose bool } -// Dialog shows a modal dialog while *open is true. It traps the focus, -// makes the window inert and gives the focus back to the opener on close. +// Dialog shows a modal dialog while *open is true, trapping and then restoring the focus. func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element { if opts.Title == "" { panic("mujica: Dialog needs a Title") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } + overlayLayer(c, open) k := Tokens(c) shown, backClicked, closed := true, false, false panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { @@ -56,6 +58,7 @@ func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.E dialogActions(c, opts.Actions) } }) + focus.shown(panel) switch { case closed: slog.Debug("mujica: dialog close", "by", "button") @@ -106,7 +109,7 @@ func dialogHeader(c *ui.Context, title, description string, decorated, closable clicked := false ui.Column(c).Gap(u).Shrink(0).Children(func() { ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() { - ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1) + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1) if closable { clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked() } diff --git a/dialog_test.go b/dialog_test.go index 3706ea7..5546998 100644 --- a/dialog_test.go +++ b/dialog_test.go @@ -2,6 +2,7 @@ package mujica import ( "testing" + "time" "github.com/egoist/mygo/ui" @@ -173,6 +174,53 @@ func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) { } } +func TestDialogEscapeHidesTooltipBeforeClosing(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Dialog(c, &open, DialogOptions{Title: "Outer", HideClose: true}, func() { + b := Button(c, "Seal", ButtonOptions{}) + Tooltip(c, b, "Seal with wax", TooltipOptions{Delay: time.Millisecond}) + }) + }, 600, 400) + tt.Key(0, ui.KeyTab) // keyboard focus on Seal + if !overlayUntil(tt, func() bool { return tt.HasText("Seal with wax") }) { + t.Fatal("tooltip did not show in the dialog") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("Seal with wax") || !open { + t.Fatalf("first Escape: tip shown %v, dialog open %v", tt.HasText("Seal with wax"), open) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("second Escape did not close the dialog") + } +} + +func TestDialogFocusFallsBackWhenOpenerIsGone(t *testing.T) { + open, gone := false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + Button(c, "First", ButtonOptions{}) + if !gone && Button(c, "Open", ButtonOptions{}).Clicked() { + open, gone = true, true + } + Dialog(c, &open, DialogOptions{Title: "Gone"}, nil) + Button(c, "Next", ButtonOptions{}) + }) + }, 600, 400) + _ = tt.Click("Open") + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Next") { + t.Fatal("focus did not fall back to the dialog's place in the window") + } +} + func TestDialogSnapshot(t *testing.T) { snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true)) snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false)) diff --git a/docs/components/081-dialog.md b/docs/components/081-dialog.md index 679d06d..73afded 100644 --- a/docs/components/081-dialog.md +++ b/docs/components/081-dialog.md @@ -71,5 +71,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 关闭后焦点回到打开时的控件;若该控件已不存在,MyGo 不回退到父级(`ui/scope.go:137` restoreFocus 只恢复原控件),焦点为空,再按 Tab 从窗口开头开始。 -- 没有关闭过渡:MyGo 在 `*open` 变为 false 的下一帧即移除浮层元素,`Transition` 的 Exit 需要父元素保留,而浮层根只在有浮层时存在。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 没有关闭过渡:`*open` 变为 false 的下一帧浮层元素即被移除。 diff --git a/docs/components/082-alert-dialog.md b/docs/components/082-alert-dialog.md index 40c2a18..b352f42 100644 --- a/docs/components/082-alert-dialog.md +++ b/docs/components/082-alert-dialog.md @@ -2,7 +2,7 @@ ## 用途 -确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未确认目标前 Enter 不会执行。 +确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未输入前确认按钮禁用;输入框中的 Enter 从不确认。 ## 最小示例 @@ -46,7 +46,7 @@ ui.Render(view, 400, 300, 1) ## 事件 -- `Confirmed`:点击确认,或在输入框内按 Enter 且目标已匹配。 +- `Confirmed`:点击确认,或焦点在已解锁的确认按钮上按 Enter / Space。 - `Cancelled`:点击取消、在取消上按 Enter / Space、或按 Escape。 - 点击遮罩不产生事件。 @@ -55,11 +55,13 @@ ui.Render(view, 400, 300, 1) | 按键 | 行为 | | --- | --- | | 打开时 | 焦点在“取消”。 | -| Enter | 触发聚焦的按钮;在输入框中只有目标匹配时确认。 | +| Enter | 触发聚焦的按钮;在输入框中不确认,Tab 到确认按钮后再按。 | | Escape | 取消并关闭。 | | Tab | 在对话框内循环。 | ## 限制 -- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(MyGo 只在键盘移动焦点后设置可见焦点,`ui/input.go:868`);按 Tab 后显示。 -- 不替代系统原生警示框;需要系统样式时用 MyGo 的桌面对话框。 +- 输入框中的 Enter 从不确认,必须用 Tab 移到确认按钮:MyGo 在 Enter 时先提交输入法组合再处理按键(`ui/editor.go:688`),而组合状态不公开,无法区分“提交组合”的 Enter。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(`ui/input.go:868`);按 Tab 后显示。 diff --git a/docs/components/083-drawer.md b/docs/components/083-drawer.md index dce31ee..5f2a080 100644 --- a/docs/components/083-drawer.md +++ b/docs/components/083-drawer.md @@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 非模态抽屉不在打开时移动焦点,关闭时也不自动恢复焦点:MyGo 记录与恢复打开者的 `openOverlay` 不公开(`ui/scope.go:123`),也没有读取当前焦点的公开接口。 -- 非模态抽屉的 Escape 是窗口级快捷键,优先级最低;同时打开多个非模态浮层时由先构建的那个响应。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 +- 非模态抽屉打开时不移动焦点,其内容排在窗口焦点顺序末尾。 diff --git a/docs/components/084-popover.md b/docs/components/084-popover.md index a1cf618..96b43a4 100644 --- a/docs/components/084-popover.md +++ b/docs/components/084-popover.md @@ -50,5 +50,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 只支持下方与上方两个位置:位置由 MyGo `PopoverBase` 决定(`ui/widgets.go:689` keepInWindow)。 -- 打开时外部点击只用于关闭,不会传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 只支持下方与上方两个位置(`ui/widgets.go:689` keepInWindow);宽度不超过窗口,过高时内容滚动。 diff --git a/docs/components/085-tooltip.md b/docs/components/085-tooltip.md index c3137b2..0700c27 100644 --- a/docs/components/085-tooltip.md +++ b/docs/components/085-tooltip.md @@ -41,5 +41,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发,避免点击后弹出提示。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发。 - 不使用 MyGo 的 `Element.Tooltip`:它只跟随指针、不响应焦点(`ui/widgets.go:549`)。 diff --git a/docs/components/086-hover-card.md b/docs/components/086-hover-card.md index 18c1dc5..16701ac 100644 --- a/docs/components/086-hover-card.md +++ b/docs/components/086-hover-card.md @@ -48,5 +48,6 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 键盘打开时卡片由 `PopoverBase` 承载,以便焦点顺序紧跟锚点;此时卡片外的点击只用于关闭(`ui/base.go:373`)。指针打开时卡片不拦截窗口其余部分。 -- 指针打开时卡片元素在窗口焦点顺序的末尾。 +- 卡片由 `PopoverBase` 承载,使焦点顺序紧跟锚点,在 Dialog 内也属于对话框的焦点范围;代价是打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 指针在卡片外但焦点仍在卡片内时卡片保持打开,点击外部或按 Escape 关闭。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/087-dropdown-menu.md b/docs/components/087-dropdown-menu.md index 605c7a7..6eb6bad 100644 --- a/docs/components/087-dropdown-menu.md +++ b/docs/components/087-dropdown-menu.md @@ -72,4 +72,5 @@ ui.Render(view, 400, 300, 1) ## 限制 - MyGo 没有 menu / menuitem 无障碍角色(`ui/access.go:22` Role 列表),菜单面板报告为 popup,普通项为 button,勾选项为 checkbox,触发按钮为 menu button。 -- 菜单打开期间外部点击只用于关闭(`ui/base.go:373`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/088-context-menu.md b/docs/components/088-context-menu.md index b37874d..f4f0117 100644 --- a/docs/components/088-context-menu.md +++ b/docs/components/088-context-menu.md @@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1) ## 限制 - `target` 必须是容器:菜单会向它追加一个不可见的定位子元素,文字元素不能有非文字子元素(MyGo 会 panic)。 -- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(MyGo 每个元素只保存一个处理函数,`ui/handler.go:73`)。 +- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(`ui/handler.go:73`)。 - 不使用 MyGo 的 `Element.ContextMenu`:那是系统菜单,PRD 不允许把它当作哥特样式菜单。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 diff --git a/docs/components/089-popconfirm.md b/docs/components/089-popconfirm.md index 7998d35..caea581 100644 --- a/docs/components/089-popconfirm.md +++ b/docs/components/089-popconfirm.md @@ -55,5 +55,5 @@ ui.Render(view, 400, 300, 1) ## 限制 -- 非模态:不屏蔽窗口,但外部点击只用于取消,不传给下方控件(`ui/base.go:373`)。 -- 位置只有下方与上方(`ui/widgets.go:689`)。 +- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。 +- 位置只有下方与上方(`ui/widgets.go:689`);宽度不超过窗口,说明过长时滚动,按钮始终可见。 diff --git a/docs/components/090-overlay.md b/docs/components/090-overlay.md index 053a732..3aac2c8 100644 --- a/docs/components/090-overlay.md +++ b/docs/components/090-overlay.md @@ -46,6 +46,7 @@ Escape 与(模态时)背景点击把 `*open` 设为 false。 ## 限制 -- 非模态覆盖层的 Escape 是窗口级快捷键(MyGo 浮层专用快捷键 `overlayShortcut` 不公开,`ui/scope.go:109`),同时打开多个非模态浮层时由先构建的那个响应。 -- 非模态覆盖层关闭时不自动恢复焦点(`ui/scope.go:123` openOverlay 不公开)。 +- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。 +- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。 +- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。 - 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。 diff --git a/drawer.go b/drawer.go index 5897a86..dee05e8 100644 --- a/drawer.go +++ b/drawer.go @@ -22,8 +22,7 @@ const ( type DrawerOptions struct { Title string Side DrawerSide - // Size is the width (left, right) or height (top, bottom): 360 or 280 DIPs when zero. - // It is kept between 160 DIPs and 90% of the window. + // Size is the width or height (360 or 280 when zero), kept within 160 DIPs and 90% of the window. Size float32 // NonModal leaves the window usable and the focus free. NonModal bool @@ -31,8 +30,7 @@ type DrawerOptions struct { Actions func() } -// Drawer shows a panel along a window edge while *open is true. A modal -// drawer traps the focus and restores it on close; Escape closes either kind. +// Drawer shows a panel along a window edge while *open is true; Escape closes it. func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element { if opts.Title == "" { panic("mujica: Drawer needs a Title") @@ -43,7 +41,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E if opts.Size < 0 { panic("mujica: Drawer Size is negative") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } k := Tokens(c) @@ -69,8 +69,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E drawerPlace(c, panel, opts.Side, size*p) panel.Children(func() { body(panel, p) }) }) - escaped = c.Shortcut(0, ui.KeyEscape) + escaped = overlayEscape(c, open) } else { + overlayLayer(c, open) shown := true panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) { p := overlayEnter(c, panel) @@ -80,6 +81,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E }) escaped = !shown } + focus.shown(panel) if closed || escaped { slog.Debug("mujica: drawer close", "escape", escaped) *open = false diff --git a/dropdown_menu.go b/dropdown_menu.go index aafd4cd..87bc084 100644 --- a/dropdown_menu.go +++ b/dropdown_menu.go @@ -15,10 +15,7 @@ type DropdownMenuOptions struct { Label string } -// DropdownMenu is a button opening an in-window menu that build fills. -// Click, Enter, Space or Down opens it with the focus on the first item; -// the arrows, Home and End move, Escape or Tab closes, and the focus -// returns to the button. It returns the button. +// DropdownMenu is a button opening an in-window menu that build fills; it returns the button. func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element { if build == nil { panic("mujica: DropdownMenu needs a build function") diff --git a/dropdown_menu_test.go b/dropdown_menu_test.go index f6b46b6..394ef59 100644 --- a/dropdown_menu_test.go +++ b/dropdown_menu_test.go @@ -145,14 +145,38 @@ func TestDropdownMenuDisabled(t *testing.T) { } } +func TestDropdownMenuTabInSubmenuClosesMenu(t *testing.T) { + a := &menuApp{} + tt := ui.NewTester(a.view, 500, 400) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Archive") { + t.Fatal("submenu not entered") + } + tt.Key(0, ui.KeyTab) + if tt.HasText("Archive") || tt.HasText("Rename") { + t.Fatal("Tab in a submenu left the menu open") + } + if !tt.Focused("Actions") { + t.Fatal("focus did not return to the trigger") + } +} + func TestDropdownMenuSnapshot(t *testing.T) { - snapshot(t, "dropdown_menu", 460, 320, func(c *ui.Context) { - a := &menuApp{pinned: true} + a := &menuApp{pinned: true} + overlaySnapshot(t, "dropdown_menu", 520, 320, func(c *ui.Context) { ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() { - b := ui.ButtonBase(c) - popupMenuTrigger(c, b, "Actions", nil, true) - open := true - popupMenuOpen(c, b, &open, a.build) + DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build) }) + }, func(tt *ui.Tester) { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) // opens with the first item focused + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyRight) // opens Move to, focus ring on Archive + if !tt.Focused("Archive") { + t.Fatal("submenu did not open") + } }) } diff --git a/examples/gallery/pages_overlay.go b/examples/gallery/pages_overlay.go index 2458fd9..e4e650c 100644 --- a/examples/gallery/pages_overlay.go +++ b/examples/gallery/pages_overlay.go @@ -15,7 +15,7 @@ var overlayPages = []page{ {ID: "081", Name: "Dialog", ZhName: "对话框", Category: overlayCategory, Demo: dialogDemo, Code: dialogCode, Keys: "Tab / Shift+Tab 在对话框内循环;Escape 关闭(可关闭此规则);关闭后焦点回到触发按钮。"}, {ID: "082", Name: "AlertDialog", ZhName: "警示对话框", Category: overlayCategory, Demo: alertDialogDemo, Code: alertDialogCode, - Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮;Escape 等同取消;点击遮罩无效。"}, + Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮,输入框中的 Enter 不确认;Escape 等同取消;点击遮罩无效。"}, {ID: "083", Name: "Drawer", ZhName: "抽屉", Category: overlayCategory, Demo: drawerDemo, Code: drawerCode, Keys: "模态:焦点限制在抽屉内并在关闭后恢复;Escape 关闭。非模态:窗口仍可操作,Escape 关闭。"}, {ID: "084", Name: "Popover", ZhName: "弹出框", Category: overlayCategory, Demo: popoverDemo, Code: popoverCode, @@ -25,7 +25,7 @@ var overlayPages = []page{ {ID: "086", Name: "HoverCard", ZhName: "悬停卡片", Category: overlayCategory, Demo: hoverCardDemo, Code: hoverCardCode, Keys: "键盘聚焦锚点后显示,Tab 进入卡片;Escape 关闭。"}, {ID: "087", Name: "DropdownMenu", ZhName: "下拉菜单", Category: overlayCategory, Demo: dropdownMenuDemo, Code: dropdownMenuCode, - Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 或 Tab 关闭。"}, + Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 关闭最上层;Tab 在任一层关闭整个菜单。"}, {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, @@ -171,10 +171,7 @@ func drawerDemo(c *ui.Context, log func(string)) { log("side = " + s.side) } overlayCheck(c, log, &s.nonModal, "NonModal") - ui.Text(c, fmt.Sprintf("Size %.0f", s.size)) - if ui.StepSlider(c, &s.size, 120, 900, 20).Width(160).Changed() { - log(fmt.Sprintf("size = %.0f", s.size)) - } + overlaySlider(c, log, &s.size, "Size", "DIP", 120, 900, 20) }) if mujica.Button(c, "Open drawer", mujica.ButtonOptions{}).Clicked() { s.open = true @@ -247,10 +244,7 @@ ui.Render(view, 400, 300, 1)` func tooltipDemo(c *ui.Context, log func(string)) { delay := ui.Local(c.Root(), "085", func() float64 { return 500 }) overlayControls(c, func() { - ui.Text(c, fmt.Sprintf("Delay %.0f ms", *delay)) - if ui.StepSlider(c, delay, 100, 1500, 100).Width(160).Changed() { - log(fmt.Sprintf("delay = %.0f ms", *delay)) - } + overlaySlider(c, log, delay, "Delay", "ms", 100, 1500, 100) }) opts := mujica.TooltipOptions{Delay: time.Duration(*delay) * time.Millisecond} ui.Row(c).Gap(12).Children(func() { @@ -276,19 +270,43 @@ const hoverCardCode = `view := func(c *ui.Context) { } ui.Render(view, 400, 300, 1)` +type hoverCardDemoState struct { + shown bool + openDelay, closeDelay, width float64 +} + func hoverCardDemo(c *ui.Context, log func(string)) { - shown := ui.Local(c.Root(), "086", func() bool { return false }) + s := ui.Local(c.Root(), "086", func() hoverCardDemoState { + return hoverCardDemoState{openDelay: 500, closeDelay: 300, width: 280} + }) + overlayControls(c, func() { + overlaySlider(c, log, &s.openDelay, "OpenDelay", "ms", 100, 1500, 100) + overlaySlider(c, log, &s.closeDelay, "CloseDelay", "ms", 100, 1500, 100) + overlaySlider(c, log, &s.width, "Width", "DIP", 200, 420, 20) + }) b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost}) - card := mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() { + card := mujica.HoverCard(c, b, mujica.HoverCardOptions{ + OpenDelay: time.Duration(s.openDelay) * time.Millisecond, + CloseDelay: time.Duration(s.closeDelay) * time.Millisecond, + Width: float32(s.width), + }, func() { ui.Text(c, "Lady Sakiko").FontWeight(600) ui.Text(c, "Keyboardist of the court; writes the masquerade's score.").TextColor(mujica.Tokens(c).TextMuted) if mujica.Button(c, "Follow", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() { log("follow") } }) - if (card != nil) != *shown { - *shown = card != nil - log(fmt.Sprintf("card shown = %v", *shown)) + if (card != nil) != s.shown { + s.shown = card != nil + log(fmt.Sprintf("card shown = %v", s.shown)) + } +} + +// overlaySlider is a labeled step slider logging its value. +func overlaySlider(c *ui.Context, log func(string), v *float64, name, unit string, lo, hi, step float64) { + ui.Text(c, fmt.Sprintf("%s %.0f %s", name, *v, unit)) + if ui.StepSlider(c, v, lo, hi, step).Width(140).Changed() { + log(fmt.Sprintf("%s = %.0f %s", name, *v, unit)) } } diff --git a/hover_card.go b/hover_card.go index 6c9d658..030e5e1 100644 --- a/hover_card.go +++ b/hover_card.go @@ -20,17 +20,12 @@ type HoverCardOptions struct { // hoverCardState is what a card keeps between frames while its anchor lives. type hoverCardState struct { - open, dismissed bool - // keyMode keeps the card in the focus order while the keyboard is on it. - keyMode bool - enterAt, leaveAt time.Time - panel ui.Rect + open, dismissed, focusIn bool + enterAt, leaveAt time.Time + panel ui.Rect } -// HoverCard previews content beside anchor while the pointer rests on it or -// it has the keyboard focus. The pointer may move into the card; with the -// keyboard, Tab from the anchor enters it. It closes after CloseDelay once -// neither holds it, or at once on Escape. It returns the card, or nil. +// HoverCard previews content by a hovered or focused anchor; nil while hidden. func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element { if anchor == nil { panic("mujica: HoverCard needs an anchor") @@ -50,11 +45,11 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content keyboard := anchor.FocusVisible() pointer := anchor.Hovered() if st.open { - // The card's layer covers the anchor, so test the pointer by geometry. + // The popover's backdrop covers the anchor, so test the pointer by geometry. x, y, in := c.Root().PointerPosition() pointer = pointer || in && (b.Contains(x, y) || st.panel.Contains(x, y)) } - hot := pointer || keyboard || st.keyMode + hot := pointer || keyboard || st.focusIn if !hot { st.dismissed = false } @@ -87,7 +82,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content c.After(wait) } else { slog.Debug("mujica: hover_card close") - st.open, st.keyMode, st.leaveAt = false, false, time.Time{} + st.open, st.focusIn, st.leaveAt = false, false, time.Time{} return nil } } @@ -95,43 +90,23 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content if width == 0 { width = 280 } - style := func(panel *ui.Element, p float32) { - overlayStyle(c, panel, p) - panel.Width(width).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).Label(opts.Label). - MaxHeight(popoverRoom(c, b)) + overlayLayer(c, st) + // A popover keeps the card in the anchor's focus order and modal scope, even inside a Dialog. + shown := true + panel := ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { + overlayStyle(c, panel, overlayAnchored(c, panel, b)) + panel.Margin(6, 0, 0, 0).Width(popoverWide(c, width)).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2). + Label(opts.Label).MaxHeight(popoverRoom(c, b)) ui.Scroll(c).Shrink(1).Children(content) - } - var panel *ui.Element - dismissed, keyMode := false, keyboard || st.keyMode - if keyMode { - shown := true - panel = ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { - style(panel.Margin(6, 0, 0, 0), overlayAnchored(c, panel, b)) - }) - dismissed = !shown - } else { - ui.Overlay(c, func() { - panel = ui.Column(c).Absolute().Role(ui.RolePopup) - prev := panel.Bounds() - x, y := overlayPlace(c, b, width, prev.H, 6) - panel.Left(x).Top(y) - p := overlayEnter(c, panel) - if prev.H == 0 { - p = 0 // not measured yet - c.AnimationFrame() - } - panel.Children(func() { style(panel, p) }) - }) - dismissed = c.Shortcut(0, ui.KeyEscape) - } - if dismissed { + }) + if !shown { slog.Debug("mujica: hover_card dismiss") - st.open, st.dismissed, st.keyMode = false, true, false - if keyMode { + if keyboard || st.focusIn { anchor.Focus() // at once, so the anchor stays hot and the card stays dismissed } + st.open, st.dismissed, st.focusIn = false, true, false return panel } - st.panel, st.keyMode = panel.Bounds(), keyboard || keyMode && panel.FocusWithin() + st.panel, st.focusIn = panel.Bounds(), panel.FocusWithin() return panel } diff --git a/hover_card_test.go b/hover_card_test.go index 815ff06..8314668 100644 --- a/hover_card_test.go +++ b/hover_card_test.go @@ -7,30 +7,45 @@ import ( "github.com/egoist/mygo/ui" ) -const hoverCardTestDelay = 20 * time.Millisecond +const hoverCardTestDelay = 150 * time.Millisecond type hoverCardApp struct { follows, next int + inDialog bool +} + +func (a *hoverCardApp) card(c *ui.Context) { + b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost}) + HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() { + ui.Text(c, "Keyboardist of the court") + if Button(c, "Follow", ButtonOptions{}).Clicked() { + a.follows++ + } + }) + if Button(c, "Next", ButtonOptions{}).Clicked() { + a.next++ + } } func (a *hoverCardApp) view(c *ui.Context) { Use(c, Settings{}) - ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { - b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost}) - HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() { - ui.Text(c, "Keyboardist of the court") - if Button(c, "Follow", ButtonOptions{}).Clicked() { - a.follows++ - } + if a.inDialog { + open := true + Dialog(c, &open, DialogOptions{Title: "Court", HideClose: true}, func() { + ui.Column(c).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) }) }) - if Button(c, "Next", ButtonOptions{}).Clicked() { - a.next++ - } - }) + return + } + ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) }) } -func hoverCardWait(tt *ui.Tester) { - time.Sleep(hoverCardTestDelay + 15*time.Millisecond) +func hoverCardShown(tt *ui.Tester) func() bool { + return func() bool { return tt.HasText("Keyboardist of the court") } +} + +// hoverCardSettle waits past both delays so any pending open or close has happened. +func hoverCardSettle(tt *ui.Tester) { + time.Sleep(hoverCardTestDelay + 30*time.Millisecond) tt.Frame() } @@ -38,34 +53,54 @@ func TestHoverCardPointerCanEnterCard(t *testing.T) { a := &hoverCardApp{} tt := ui.NewTester(a.view, 500, 400) r, _ := tt.Find("Lady Sakiko") + start := time.Now() tt.Move(r.X+5, r.Y+5) - if tt.HasText("Keyboardist of the court") { + if time.Since(start) < hoverCardTestDelay && tt.HasText("Keyboardist of the court") { t.Fatal("opened before the delay") } - hoverCardWait(tt) - tt.Frame() - f, ok := tt.Find("Follow") - if !ok { + if !overlayUntil(tt, hoverCardShown(tt)) { t.Fatalf("card did not open: %q", tt.Texts()) } - // Moving into the card keeps it; its button works. - tt.Move(f.X+5, f.Y+5) - hoverCardWait(tt) + // The pointer inside the card keeps it open past the close delay. + k, _ := tt.Find("Keyboardist of the court") + tt.Move(k.X+5, k.Y+5) + hoverCardSettle(tt) + if !tt.HasText("Follow") { + t.Fatal("card closed with the pointer inside it") + } if err := tt.Click("Follow"); err != nil { t.Fatal(err) } - if a.follows != 1 { - t.Fatalf("follows %d", a.follows) + if a.follows != 1 || !tt.Focused("Follow") { + t.Fatalf("follows %d, Follow focused %v", a.follows, tt.Focused("Follow")) } - // Leaving both closes it after the delay, and the window stays usable. + // With the focus inside, leaving keeps it; a click outside closes it. tt.Move(490, 390) - tt.Frame() + hoverCardSettle(tt) if !tt.HasText("Follow") { + t.Fatal("card closed with the focus inside it") + } + tt.ClickAt(490, 390) + if tt.HasText("Follow") { + t.Fatal("outside click did not close the card") + } +} + +func TestHoverCardCloseDelay(t *testing.T) { + a := &hoverCardApp{} + tt := ui.NewTester(a.view, 500, 400) + r, _ := tt.Find("Lady Sakiko") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, hoverCardShown(tt)) { + t.Fatal("card did not open") + } + start := time.Now() + tt.Move(490, 390) + if time.Since(start) < hoverCardTestDelay && !tt.HasText("Follow") { t.Fatal("closed without the close delay") } - hoverCardWait(tt) - if tt.HasText("Follow") { - t.Fatal("card stayed open") + if !overlayUntil(tt, func() bool { return !tt.HasText("Follow") }) { + t.Fatal("card stayed open after the close delay") } } @@ -73,8 +108,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { a := &hoverCardApp{} tt := ui.NewTester(a.view, 500, 400) tt.Key(0, ui.KeyTab) - hoverCardWait(tt) - if !tt.HasText("Keyboardist of the court") { + if !overlayUntil(tt, hoverCardShown(tt)) { t.Fatal("no card on keyboard focus") } tt.Key(0, ui.KeyTab) @@ -89,7 +123,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { if tt.HasText("Follow") || !tt.Focused("Lady Sakiko") { t.Fatalf("Escape: card shown %v, anchor focused %v", tt.HasText("Follow"), tt.Focused("Lady Sakiko")) } - hoverCardWait(tt) + hoverCardSettle(tt) if tt.HasText("Follow") { t.Fatal("dismissed card came back while focused") } @@ -99,19 +133,43 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) { } } +func TestHoverCardInsideDialog(t *testing.T) { + a := &hoverCardApp{inDialog: true} + tt := ui.NewTester(a.view, 600, 500) + r, _ := tt.Find("Lady Sakiko") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, hoverCardShown(tt)) { + t.Fatal("card did not open in the dialog") + } + if err := tt.Click("Follow"); err != nil { + t.Fatal(err) + } + if a.follows != 1 || !tt.Focused("Follow") { + t.Fatalf("follows %d, focus kept on Follow %v", a.follows, tt.Focused("Follow")) + } + tt.Key(ui.Shift, ui.KeyTab) + if !tt.Focused("Lady Sakiko") { + t.Fatal("Shift+Tab from the card did not reach its anchor in the dialog") + } +} + func TestHoverCardSnapshot(t *testing.T) { - snapshot(t, "hover_card", 420, 240, func(c *ui.Context) { + overlaySnapshot(t, "hover_card", 420, 240, func(c *ui.Context) { ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { - Button(c, "Lady Uika", ButtonOptions{Variant: Ghost}) - }) - ui.Overlay(c, func() { - p := ui.Column(c).Absolute().Left(24).Top(66) - overlayStyle(c, p, 1) - p.Width(280).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() { - ui.Text(c, "Lady Uika").FontWeight(600) - ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted) - Button(c, "Follow", ButtonOptions{Variant: Outline}) + b := Button(c, "Lady Uika", ButtonOptions{Variant: Ghost}) + HoverCard(c, b, HoverCardOptions{OpenDelay: time.Millisecond}, func() { + ui.Column(c).Gap(8).AlignItems(ui.Start).Children(func() { + ui.Text(c, "Lady Uika").FontWeight(600) + ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted) + Button(c, "Follow", ButtonOptions{Variant: Outline}) + }) }) }) + }, func(tt *ui.Tester) { + r, _ := tt.Find("Lady Uika") + tt.Move(r.X+5, r.Y+5) + if !overlayUntil(tt, func() bool { return tt.HasText("Follow") }) { + t.Fatal("card did not open") + } }) } diff --git a/overlay.go b/overlay.go index 7b4a4a0..e328e9b 100644 --- a/overlay.go +++ b/overlay.go @@ -37,8 +37,7 @@ type OverlayOptions struct { Label string } -// Overlay shows content in a layer above the window while *open is true. -// Escape closes it; a modal one also closes on a backdrop click. +// Overlay shows content in a layer above the window while *open is true; Escape closes it. func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element { if opts.Dim && !opts.Modal { panic("mujica: Overlay Dim requires Modal") @@ -46,7 +45,9 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui if opts.Place < OverlayCenter || opts.Place > OverlayBottomRight { panic("mujica: Overlay Place out of range") } + site, focus := overlaySite(c) if !*open { + focus.closed(c, site) return nil } k := Tokens(c) @@ -56,6 +57,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui } fx, fy := overlayFractions(opts.Place) if opts.Modal { + overlayLayer(c, open) var backClicked bool shown := true panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) { @@ -69,6 +71,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui panel.Label(opts.Label).Padding(Density(c).Unit() * 4) content() }) + focus.shown(panel) if !shown { slog.Debug("mujica: overlay close", "backdrop", backClicked) *open = false @@ -89,7 +92,8 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui overlayStyle(c, panel, p) panel.Children(content) }) - if c.Shortcut(0, ui.KeyEscape) { + focus.shown(panel) + if overlayEscape(c, open) { slog.Debug("mujica: overlay close", "escape", true) *open = false } diff --git a/overlay_layers.go b/overlay_layers.go new file mode 100644 index 0000000..6b9c419 --- /dev/null +++ b/overlay_layers.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" +) + +// overlayLayers is the window's open MujicaUI layers, oldest first. +type overlayLayers struct { + frame, prev time.Time + keys []any + seen []time.Time +} + +type overlayLayersKey struct{} + +// overlayLayer marks the layer key open in this frame and reports whether it is on top. +func overlayLayer(c *ui.Context, key any) bool { + s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} }) + if now := c.Now(); now != s.frame { + s.prev, s.frame = s.frame, now + n := 0 + for i, k := range s.keys { + if !s.seen[i].Before(s.prev) { // drop layers the last frame did not build + s.keys[n], s.seen[n] = k, s.seen[i] + n++ + } + } + clear(s.keys[n:]) + s.keys, s.seen = s.keys[:n], s.seen[:n] + } + at := -1 + for i, k := range s.keys { + if k == key { + at = i + } + } + if at < 0 { + s.keys, s.seen = append(s.keys, key), append(s.seen, s.frame) + at = len(s.keys) - 1 + } + s.seen[at] = s.frame + return at == len(s.keys)-1 +} + +// overlayEscape reports Escape for a window-level layer, delivered only while it is on top. +func overlayEscape(c *ui.Context, key any) bool { + if !overlayLayer(c, key) { + return false + } + // The root is around every focus, so it takes Escape before MyGo's overlays. + byFocus := c.Root().Shortcut(0, ui.KeyEscape) + byWindow := c.Shortcut(0, ui.KeyEscape) + return byFocus || byWindow +} + +// overlaySiteState follows the focus of a layer after it closes. +type overlaySiteState struct { + focusIn bool // the focus was inside the layer when it last showed + wait int // frames left to check the focus after a close + holding bool // the site holds the fallback focus +} + +// overlaySite builds the invisible focus fallback at a layer's call site. +func overlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) { + site := ui.Box(c).Size(0, 0).FocusRing(false) + st := ui.Local(site, "mujica.overlay.site", func() overlaySiteState { return overlaySiteState{} }) + st.holding = st.holding && site.FocusWithin() + if st.holding { + site.Focusable() // in flow, so Tab goes on from the layer's place + } else { + site.Absolute() + } + return site, st +} + +// shown records whether the focus is in the open layer's panel. +func (st *overlaySiteState) shown(panel *ui.Element) { + st.focusIn, st.wait = panel != nil && panel.FocusWithin(), 0 +} + +// closed moves a lost focus to the site once MyGo has had its chance to restore it. +func (st *overlaySiteState) closed(c *ui.Context, site *ui.Element) { + if st.focusIn { + st.focusIn, st.wait = false, 2 // MyGo prunes the panel at the end of the next frame + } + if st.wait == 0 { + return + } + st.wait-- + if st.wait > 0 { + c.AnimationFrame() + return + } + // The site itself counts as lost: it may inherit a vanished opener's positional ID. + if !c.Root().FocusWithin() || site.FocusWithin() { + slog.Debug("mujica: overlay focus fallback") + site.Focus() + st.holding = true + } +} diff --git a/overlay_snapshot_test.go b/overlay_snapshot_test.go new file mode 100644 index 0000000..4388b69 --- /dev/null +++ b/overlay_snapshot_test.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// overlaySnapshot is snapshot, with drive opening the real component through input before the capture. +func overlaySnapshot(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) { + t.Helper() + for _, m := range []Mode{Light, Dark} { + ready := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: m}) + if ready { + view(c) + } + }, w, h) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + ready = true + tt.Frame() + drive(tt) + got := encode(t, tt.Image()) + path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png") + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + 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) + } + } +} + +// overlayUntil draws frames until cond holds, for at most 5 s of wall time. +func overlayUntil(tt *ui.Tester, cond func() bool) bool { + end := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(end) { + return false + } + time.Sleep(2 * time.Millisecond) + tt.Frame() + } + return true +} diff --git a/overlay_test.go b/overlay_test.go index c66bc7a..5fa3e8a 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -3,6 +3,7 @@ package mujica import ( "runtime" "testing" + "time" "github.com/egoist/mygo/ui" ) @@ -93,6 +94,57 @@ func TestOverlayNonModal(t *testing.T) { } } +func TestOverlayEscapeClosesTopNonModalLayerFirst(t *testing.T) { + bottom, top := false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Show", ButtonOptions{}).Clicked() { + if bottom { + top = true + } + bottom = true + } + Overlay(c, &top, OverlayOptions{Place: OverlayTopRight}, func() { ui.Text(c, "Top layer") }) + Drawer(c, &bottom, DrawerOptions{Title: "Bottom layer", NonModal: true, Side: DrawerLeft}, nil) + }, 600, 400) + _ = tt.Click("Show") + _ = tt.Click("Show") // the overlay opens after the drawer, though built before it + if !bottom || !top { + t.Fatalf("bottom %v top %v", bottom, top) + } + tt.Key(0, ui.KeyEscape) + if top || !bottom { + t.Fatalf("first Escape: top %v bottom %v", top, bottom) + } + tt.Key(0, ui.KeyEscape) + if bottom { + t.Fatal("second Escape did not close the drawer") + } +} + +func TestOverlayNonModalRestoresFocusToItsPlace(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + Button(c, "First", ButtonOptions{}) + Overlay(c, &open, OverlayOptions{}, func() { Button(c, "Inside", ButtonOptions{}) }) + Button(c, "Next", ButtonOptions{}) + }) + }, 600, 400) + if err := tt.Click("Inside"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Next") { + t.Fatal("focus lost on close instead of staying at the overlay's place") + } +} + func TestOverlayDimNeedsModal(t *testing.T) { defer func() { if recover() == nil { @@ -106,78 +158,114 @@ func TestOverlayDimNeedsModal(t *testing.T) { }, 200, 200) } -// TestOverlayLifecycle opens and closes every overlay 100 times and checks -// that nothing grows: goroutines, heap objects or the elements left behind. -func TestOverlayLifecycle(t *testing.T) { - var open bool - views := map[string]func(c *ui.Context, b *ui.Element){ - "Dialog": func(c *ui.Context, b *ui.Element) { Dialog(c, &open, DialogOptions{Title: "T"}, func() {}) }, - "Alert": func(c *ui.Context, b *ui.Element) { - AlertDialog(c, &open, AlertDialogOptions{Title: "T", ConfirmText: "x"}) - }, - "Drawer": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T"}, nil) }, - "Drawer2": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T", NonModal: true}, nil) }, - "Popover": func(c *ui.Context, b *ui.Element) { Popover(c, b, &open, PopoverOptions{}, func() {}) }, - "Confirm": func(c *ui.Context, b *ui.Element) { Popconfirm(c, b, &open, PopconfirmOptions{Title: "T"}) }, - "Overlay": func(c *ui.Context, b *ui.Element) { Overlay(c, &open, OverlayOptions{Modal: true}, func() {}) }, - "Menu": func(c *ui.Context, b *ui.Element) { - popupMenuOpen(c, b, &open, (&menuApp{}).build) - }, - "Context": nil, +// overlayCase drives one component open and closed through real input. +type overlayCase struct { + name string + view func(c *ui.Context, open *bool) + open, close func(tt *ui.Tester) + marker string +} + +func overlayCases() []overlayCase { + clickAnchor := func(tt *ui.Tester) { _ = tt.Click("Anchor") } + escape := func(tt *ui.Tester) { tt.Key(0, ui.KeyEscape) } + hover := func(tt *ui.Tester) { + r, _ := tt.Find("Anchor") + tt.Move(r.X+4, r.Y+4) } - for name, view := range views { + leave := func(tt *ui.Tester) { tt.Move(590, 390) } + anchor := func(c *ui.Context, open *bool) *ui.Element { + b := Button(c, "Anchor", ButtonOptions{}) + if b.Clicked() { + *open = true + } + return b + } + body := func(c *ui.Context) func() { return func() { ui.Text(c, "Layer body") } } + return []overlayCase{ + {"Dialog", func(c *ui.Context, open *bool) { + anchor(c, open) + Dialog(c, open, DialogOptions{Title: "T"}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"AlertDialog", func(c *ui.Context, open *bool) { + anchor(c, open) + AlertDialog(c, open, AlertDialogOptions{Title: "Layer body", ConfirmText: "x"}) + }, clickAnchor, escape, "Layer body"}, + {"Drawer", func(c *ui.Context, open *bool) { + anchor(c, open) + Drawer(c, open, DrawerOptions{Title: "T"}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"DrawerNonModal", func(c *ui.Context, open *bool) { + anchor(c, open) + Drawer(c, open, DrawerOptions{Title: "T", NonModal: true}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"Popover", func(c *ui.Context, open *bool) { + Popover(c, anchor(c, open), open, PopoverOptions{}, body(c)) + }, clickAnchor, escape, "Layer body"}, + {"Tooltip", func(c *ui.Context, open *bool) { + Tooltip(c, anchor(c, open), "Layer body", TooltipOptions{Delay: time.Millisecond}) + }, hover, leave, "Layer body"}, + {"HoverCard", func(c *ui.Context, open *bool) { + HoverCard(c, anchor(c, open), HoverCardOptions{OpenDelay: time.Millisecond, CloseDelay: time.Millisecond}, body(c)) + }, hover, leave, "Layer body"}, + {"DropdownMenu", func(c *ui.Context, open *bool) { + DropdownMenu(c, "Anchor", DropdownMenuOptions{}, (&menuApp{}).build) + }, clickAnchor, escape, "Rename"}, + {"ContextMenu", func(c *ui.Context, open *bool) { + row := ui.Row(c) + row.Children(func() { Button(c, "Anchor", ButtonOptions{}) }) + ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build) + }, func(tt *ui.Tester) { + clickAnchor(tt) + tt.Key(ui.Shift, ui.KeyF10) + }, escape, "Rename"}, + {"Popconfirm", func(c *ui.Context, open *bool) { + Popconfirm(c, anchor(c, open), open, PopconfirmOptions{Title: "Layer body"}) + }, clickAnchor, escape, "Layer body"}, + {"Overlay", func(c *ui.Context, open *bool) { + anchor(c, open) + Overlay(c, open, OverlayOptions{Modal: true}, body(c)) + }, clickAnchor, escape, "Layer body"}, + } +} + +// TestOverlayLifecycle opens and closes every overlay 100 times; goroutines, heap objects and elements must not grow. +func TestOverlayLifecycle(t *testing.T) { + for _, oc := range overlayCases() { + open := false tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - row := ui.Row(c) - var b *ui.Element - row.Children(func() { b = Button(c, "Anchor", ButtonOptions{}) }) - if view == nil { - ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build) - } else { - view(c, b) - } + oc.view(c, &open) }, 600, 400) tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) - cycle := func() { - open = true - if view == nil { - _ = tt.Click("Anchor") - tt.Key(ui.Shift, ui.KeyF10) - if !tt.HasText("Rename") { - t.Fatalf("%s: did not open", name) - } + shown := func() bool { return tt.HasText(oc.marker) } + cycle := func(i int) { + oc.open(tt) + if !overlayUntil(tt, shown) { + t.Fatalf("%s: cycle %d did not open", oc.name, i) } - tt.Frame() - if name != "Drawer2" { - tt.Key(0, ui.KeyEscape) - if view == nil { - open = false - } - } else { - open = false - tt.Frame() - } - if open || tt.HasText("Rename") { - t.Fatalf("%s: Escape did not close", name) + oc.close(tt) + if !overlayUntil(tt, func() bool { return !shown() }) { + t.Fatalf("%s: cycle %d did not close", oc.name, i) } } - for range 20 { - cycle() + for i := range 20 { + cycle(i) } texts := len(tt.Texts()) before := heapObjects() goroutines := runtime.NumGoroutine() - for range 100 { - cycle() + for i := range 100 { + cycle(i) } if got := len(tt.Texts()); got != texts { - t.Errorf("%s: texts %d -> %d", name, texts, got) + t.Errorf("%s: texts %d -> %d", oc.name, texts, got) } if g := runtime.NumGoroutine(); g > goroutines { - t.Errorf("%s: goroutines %d -> %d", name, goroutines, g) + t.Errorf("%s: goroutines %d -> %d", oc.name, goroutines, g) } if after := heapObjects(); after > before+2000 { - t.Errorf("%s: heap objects %d -> %d", name, before, after) + t.Errorf("%s: heap objects %d -> %d", oc.name, before, after) } } } diff --git a/popconfirm.go b/popconfirm.go index 5f2c9b8..f4bc647 100644 --- a/popconfirm.go +++ b/popconfirm.go @@ -25,9 +25,7 @@ type PopconfirmResult struct { Cancelled bool } -// Popconfirm asks for a light confirmation beside anchor while *open is -// true. Confirm takes the focus; Cancel, Escape and an outside click cancel. -// Use AlertDialog for high-risk actions. +// Popconfirm asks for a light confirmation by anchor; use AlertDialog for high-risk actions. func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult { if anchor == nil { panic("mujica: Popconfirm needs an anchor") @@ -39,7 +37,7 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp if !*open { return res } - k := Tokens(c) + overlayLayer(c, open) u := Density(c).Unit() confirm, cancel := opts.ConfirmLabel, opts.CancelLabel if confirm == "" { @@ -56,22 +54,11 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp shown := true ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(320).Role(ui.RoleAlertDialog). + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(popoverWide(c, 320)).MaxHeight(popoverRoom(c, b)).Role(ui.RoleAlertDialog). Label(opts.Title).Description(opts.Description) - ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { - color := k.Warning - if opts.Danger { - color = k.Danger - } - ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0) - ui.Column(c).Gap(u).Shrink(1).Children(func() { - ui.Text(c, opts.Title).FontWeight(600) - if opts.Description != "" { - ui.Text(c, opts.Description).TextColor(k.TextMuted) - } - }) - }) - ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() { + // 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() { res.Cancelled = true } @@ -89,3 +76,22 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp } return res } + +// popconfirmText is the icon, title and description of a Popconfirm. +func popconfirmText(c *ui.Context, opts PopconfirmOptions) { + k := Tokens(c) + u := Density(c).Unit() + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + color := k.Warning + if opts.Danger { + color = k.Danger + } + ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0) + ui.Column(c).Gap(u).Shrink(1).Children(func() { + ui.Text(c, opts.Title).FontWeight(600) + if opts.Description != "" { + ui.Text(c, opts.Description).TextColor(k.TextMuted) + } + }) + }) +} diff --git a/popconfirm_test.go b/popconfirm_test.go index 32aef2a..b939a45 100644 --- a/popconfirm_test.go +++ b/popconfirm_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strings" "testing" "github.com/egoist/mygo/ui" @@ -59,6 +60,25 @@ func TestPopconfirmCancelPaths(t *testing.T) { } } +func TestPopconfirmKeepsActionsInWindow(t *testing.T) { + long := strings.Repeat("The east gate stays unwatched tonight. ", 60) + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(30).Children(func() { + b := Button(c, "Archive", ButtonOptions{}) + Popconfirm(c, b, &open, PopconfirmOptions{Title: "Archive?", Description: long}) + }) + }, 400, 300) + tt.Frame() + for _, label := range []string{"Confirm", "Cancel"} { + r, ok := tt.Find(label) + if !ok || r.Y < 0 || r.Y+r.H > 300 || r.X+r.W > 400 { + t.Fatalf("%s at %+v outside the 400×300 window", label, r) + } + } +} + func TestPopconfirmSnapshot(t *testing.T) { snapshot(t, "popconfirm", 420, 220, func(c *ui.Context) { open := true diff --git a/popover.go b/popover.go index aef1a18..609a16d 100644 --- a/popover.go +++ b/popover.go @@ -18,9 +18,7 @@ type PopoverOptions struct { Label string } -// Popover shows content in a panel below anchor while *open is true. The -// panel flips above where there is no room, stays in the window and scrolls -// when taller than the room; an outside click or Escape closes it. +// Popover shows content below anchor, or above it, inside the window while *open is true. func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element { if anchor == nil { panic("mujica: Popover needs an anchor") @@ -28,6 +26,7 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, if !*open { return nil } + overlayLayer(c, open) u := Density(c).Unit() b := anchor.Bounds() label := opts.Label @@ -36,14 +35,14 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, } panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(b.W).MaxHeight(popoverRoom(c, b)).Label(label) + panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(popoverWide(c, b.W)).MaxHeight(popoverRoom(c, b)).Label(label) if opts.Width > 0 { - panel.Width(opts.Width) + panel.Width(popoverWide(c, opts.Width)) } else { - panel.MaxWidth(360) + panel.MaxWidth(popoverWide(c, 360)) } if opts.Title != "" { - ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).FontWeight(600).Shrink(0) + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+2)).FontWeight(600).Shrink(0) } ui.Scroll(c).Shrink(1).Children(content) }) @@ -58,3 +57,9 @@ func popoverRoom(c *ui.Context, b ui.Rect) float32 { _, h := c.Size() return max(b.Y, h-b.Y-b.H) - 16 } + +// popoverWide caps a panel width w to the window. +func popoverWide(c *ui.Context, w float32) float32 { + ww, _ := c.Size() + return min(w, ww-8) +} diff --git a/popover_test.go b/popover_test.go index a5226b5..db3887e 100644 --- a/popover_test.go +++ b/popover_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strings" "testing" "github.com/egoist/mygo/ui" @@ -76,6 +77,27 @@ func TestPopoverOutsideClickAndEscape(t *testing.T) { } } +func TestPopoverFitsWindow(t *testing.T) { + open := true + var panel ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(30).Children(func() { + b := Button(c, "Filter", ButtonOptions{}) + if p := Popover(c, b, &open, PopoverOptions{Width: 1000}, func() { + ui.Text(c, strings.Repeat("Long row of guests. ", 200)) + Button(c, "Bottom", ButtonOptions{}) + }); p != nil { + panel = p.Bounds() + } + }) + }, 400, 300) + tt.Frame() + if panel.W > 400 || panel.X < 0 || panel.Y+panel.H > 300 { + t.Fatalf("panel %+v outside the 400×300 window", panel) + } +} + func TestPopoverNeedsAnchor(t *testing.T) { defer func() { if recover() == nil { diff --git a/popup_menu.go b/popup_menu.go index 0b3db9e..d0d272a 100644 --- a/popup_menu.go +++ b/popup_menu.go @@ -17,8 +17,7 @@ type PopupMenuItemOptions struct { KeepOpen bool } -// PopupMenu builds the items of a DropdownMenu or ContextMenu. Its methods -// add items in order and report choices in the frame they happen. +// PopupMenu builds the items of a DropdownMenu or ContextMenu, reporting choices as they happen. type PopupMenu struct { c *ui.Context root *popupMenuRoot @@ -43,8 +42,7 @@ type popupMenuSub struct { build func(m *PopupMenu) } -// Item adds an item showing label and reports whether it was chosen. -// Choosing it closes the menu unless KeepOpen is set. +// Item adds an item and reports its choice, which closes the menu unless KeepOpen. func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool { it := m.row(ui.ButtonBase(m.c), opts.Disabled, false) m.content(it, label, false, false) @@ -68,8 +66,7 @@ func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions return true } -// Submenu adds an item opening a nested menu that build fills. Hover, -// click, Enter, Space or Right opens it; Left or Escape closes it. +// Submenu adds an item opening a nested menu that build fills. func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) { if build == nil { panic("mujica: PopupMenu Submenu needs a build function") @@ -95,7 +92,7 @@ func (m *PopupMenu) Group(label string, fn func()) { m.Separator() } if label != "" { - ui.Text(m.c, label).FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) + ui.Text(m.c, label).FontSize(FontSize(m.c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0) } fn() } @@ -115,7 +112,8 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { m.rows++ seq := m.seq it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). - Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).FocusRing(false).TextColor(k.Text) + Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.Text) + internal.Ring(it, k.Focus, theme.ControlRadius) if disabled { return it } @@ -135,7 +133,7 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element { } *was = hovered if hovered || it.FocusVisible() || m.level.open == seq { - it.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) { + it.Background(k.Selection).Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) }) } @@ -163,13 +161,15 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m * if !*open { return nil } + overlayLayer(c, open) b := anchor.Bounds() root := &popupMenuRoot{} panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) { overlayStyle(c, panel, overlayAnchored(c, panel, b)) - panel.Margin(4, 0, 0, 0).MinWidth(max(b.W, 200)).MaxWidth(360).MaxHeight(popoverRoom(c, b)) - items := popupMenuLevelBuild(c, panel, root, build, true) - if items.Shortcut(0, ui.KeyTab) || items.Shortcut(ui.Shift, ui.KeyTab) { + panel.Margin(4, 0, 0, 0).MinWidth(popoverWide(c, max(b.W, 200))).MaxWidth(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b)) + popupMenuLevelBuild(c, panel, root, build, true) + // The panel holds every level, so Tab in a submenu closes the whole menu too. + if panel.Shortcut(0, ui.KeyTab) || panel.Shortcut(ui.Shift, ui.KeyTab) { root.close = true } }) @@ -183,9 +183,10 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m * } // popupMenuLevelBuild builds one level's items in outer, then its open submenu. -func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) *ui.Element { +func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) { u := Density(c).Unit() - items := ui.Scroll(c).Shrink(1).Padding(u).FocusGroup(ui.Vertical) + // The padding leaves room for the items' focus rings inside the clipping scroll. + items := ui.Scroll(c).Shrink(1).Padding(u + theme.FocusGap + theme.FocusWidth).FocusGroup(ui.Vertical) m := &PopupMenu{c: c, root: root, autofocus: autofocus} m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} }) outer.Role(ui.RolePopup) @@ -195,7 +196,6 @@ func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, popupMenuSubmenu(c, outer, root, m.level, s) } } - return items } // popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window. diff --git a/testdata/snapshots/darwin/context_menu-dark.png b/testdata/snapshots/darwin/context_menu-dark.png index 9cda9f7..199f092 100644 Binary files a/testdata/snapshots/darwin/context_menu-dark.png and b/testdata/snapshots/darwin/context_menu-dark.png differ diff --git a/testdata/snapshots/darwin/context_menu-light.png b/testdata/snapshots/darwin/context_menu-light.png index 6699812..9f58256 100644 Binary files a/testdata/snapshots/darwin/context_menu-light.png and b/testdata/snapshots/darwin/context_menu-light.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-dark.png b/testdata/snapshots/darwin/dropdown_menu-dark.png index 4213dd3..4c587fa 100644 Binary files a/testdata/snapshots/darwin/dropdown_menu-dark.png and b/testdata/snapshots/darwin/dropdown_menu-dark.png differ diff --git a/testdata/snapshots/darwin/dropdown_menu-light.png b/testdata/snapshots/darwin/dropdown_menu-light.png index 426e388..f8b2b87 100644 Binary files a/testdata/snapshots/darwin/dropdown_menu-light.png and b/testdata/snapshots/darwin/dropdown_menu-light.png differ diff --git a/testdata/snapshots/darwin/hover_card-dark.png b/testdata/snapshots/darwin/hover_card-dark.png index 8de2e5e..6775937 100644 Binary files a/testdata/snapshots/darwin/hover_card-dark.png and b/testdata/snapshots/darwin/hover_card-dark.png differ diff --git a/testdata/snapshots/darwin/hover_card-light.png b/testdata/snapshots/darwin/hover_card-light.png index 1efdfc9..2a5f55f 100644 Binary files a/testdata/snapshots/darwin/hover_card-light.png and b/testdata/snapshots/darwin/hover_card-light.png differ diff --git a/testdata/snapshots/darwin/tooltip-dark.png b/testdata/snapshots/darwin/tooltip-dark.png index b91ffc5..ae15482 100644 Binary files a/testdata/snapshots/darwin/tooltip-dark.png and b/testdata/snapshots/darwin/tooltip-dark.png differ diff --git a/testdata/snapshots/darwin/tooltip-light.png b/testdata/snapshots/darwin/tooltip-light.png index 1e1c840..e114a88 100644 Binary files a/testdata/snapshots/darwin/tooltip-light.png and b/testdata/snapshots/darwin/tooltip-light.png differ diff --git a/tooltip.go b/tooltip.go index ee82404..95bbbd8 100644 --- a/tooltip.go +++ b/tooltip.go @@ -23,10 +23,7 @@ type tooltipState struct { const tooltipMaxWidth = 240 -// Tooltip shows text near anchor once the pointer rests on it or it has -// the keyboard focus. The tip holds no interactive content, passes the -// pointer through, stays in the window and hides on leave, blur or Escape. -// It also describes anchor to assistive technology. +// Tooltip shows a non-interactive tip by anchor after hover or keyboard focus rests on it for Delay. func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions) *ui.Element { if anchor == nil { panic("mujica: Tooltip needs an anchor") @@ -58,7 +55,7 @@ func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions c.After(wait) return anchor } - if c.Shortcut(0, ui.KeyEscape) { + if overlayEscape(c, st) { slog.Debug("mujica: tooltip dismiss") st.dismissed = true return anchor @@ -71,7 +68,7 @@ func Tooltip(c *ui.Context, anchor *ui.Element, text string, opts TooltipOptions func tooltipShow(c *ui.Context, a ui.Rect, text string) { k := Tokens(c) u := Density(c).Unit() - size := theme.CaptionSize + 1 + size := FontSize(c, theme.CaptionSize+1) tw, th := c.MeasureText(tooltipMaxWidth, ui.Span{Text: text, Size: size, Font: c.Theme().Font}) padX, padY := u*2.5, u*1.5 w, h := tw+2*(padX+theme.BorderWidth)+1, th+2*(padY+theme.BorderWidth) diff --git a/tooltip_test.go b/tooltip_test.go index 190da23..8f68a89 100644 --- a/tooltip_test.go +++ b/tooltip_test.go @@ -7,7 +7,7 @@ import ( "github.com/egoist/mygo/ui" ) -const tooltipTestDelay = 20 * time.Millisecond +const tooltipTestDelay = 150 * time.Millisecond func tooltipTester(x, y float32) (*ui.Tester, *int) { clicks := 0 @@ -24,16 +24,17 @@ func tooltipTester(x, y float32) (*ui.Tester, *int) { return tt, &clicks } -func waitTooltip(tt *ui.Tester) { - time.Sleep(tooltipTestDelay + 10*time.Millisecond) - tt.Frame() +// waitTooltip waits until the tip shows, however slow the machine. +func waitTooltip(tt *ui.Tester) bool { + return overlayUntil(tt, func() bool { return tt.HasText("Share with the court") }) } func TestTooltipHoverDelay(t *testing.T) { tt, clicks := tooltipTester(40, 40) r, _ := tt.Find("Share") + start := time.Now() tt.Move(r.X+4, r.Y+4) - if tt.HasText("Share with the court") { + if time.Since(start) < tooltipTestDelay && tt.HasText("Share with the court") { t.Fatal("tip showed before the delay") } waitTooltip(tt) @@ -41,7 +42,6 @@ func TestTooltipHoverDelay(t *testing.T) { if !ok || tip.Y < r.Y+r.H { t.Fatalf("tip %+v ok %v below %+v", tip, ok, r) } - // The tip passes the pointer through: clicking it clicks what is below. tt.Move(400, 300) tt.Frame() if tt.HasText("Share with the court") { @@ -56,15 +56,15 @@ func TestTooltipHoverDelay(t *testing.T) { func TestTooltipFocusAndEscape(t *testing.T) { tt, _ := tooltipTester(40, 40) tt.Key(0, ui.KeyTab) - waitTooltip(tt) - if !tt.HasText("Share with the court") { + if !waitTooltip(tt) { t.Fatal("no tip on keyboard focus") } tt.Key(0, ui.KeyEscape) if tt.HasText("Share with the court") { t.Fatal("Escape did not hide the tip") } - waitTooltip(tt) + time.Sleep(tooltipTestDelay + 30*time.Millisecond) + tt.Frame() if tt.HasText("Share with the court") { t.Fatal("tip came back while still focused") } @@ -81,11 +81,33 @@ func TestTooltipEdgeFlips(t *testing.T) { } } +func TestTooltipScalesWithText(t *testing.T) { + widths := map[float32]float32{} + for _, scale := range []float32{1, 1.5} { + tt, _ := tooltipTester(20, 20) + tt.SetPreferences(ui.Preferences{TextScale: scale}) + tt.Key(0, ui.KeyTab) + if !overlayUntil(tt, func() bool { return tt.HasText("Share with the court") }) { + t.Fatalf("scale %v: no tip", scale) + } + r, _ := tt.Find("Share with the court") + widths[scale] = r.W + } + if widths[1.5] < widths[1]*1.3 { + t.Fatalf("tip text width %v at 1.5x, %v at 1x: not scaled", widths[1.5], widths[1]) + } +} + func TestTooltipSnapshot(t *testing.T) { - snapshot(t, "tooltip", 360, 140, func(c *ui.Context) { + overlaySnapshot(t, "tooltip", 360, 140, func(c *ui.Context) { ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() { - Button(c, "Seal", ButtonOptions{Variant: Secondary}) - tooltipShow(c, ui.Rect{X: 24, Y: 24, W: 62, H: 36}, "Seal the letter with wax") + b := Button(c, "Seal", ButtonOptions{Variant: Secondary}) + Tooltip(c, b, "Seal the letter with wax", TooltipOptions{Delay: time.Millisecond}) }) + }, func(tt *ui.Tester) { + tt.Key(0, ui.KeyTab) + if !overlayUntil(tt, func() bool { return tt.HasText("Seal the letter with wax") }) { + t.Fatal("tip did not show") + } }) }