Overlays: review r1 fixes
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 的下一帧浮层元素即被移除。
|
||||
@@ -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 后显示。
|
||||
@@ -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`)。
|
||||
- 非模态抽屉打开时不移动焦点,其内容排在窗口焦点顺序末尾。
|
||||
@@ -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);宽度不超过窗口,过高时内容滚动。
|
||||
@@ -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`)。
|
||||
@@ -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 浮层。
|
||||
@@ -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 浮层。
|
||||
@@ -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 浮层。
|
||||
@@ -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`);宽度不超过窗口,说明过长时滚动,按钮始终可见。
|
||||
@@ -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`)。
|
||||
- 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||