diff --git a/docs/components/014-image.md b/docs/components/014-image.md
index b7db4fb..f730439 100644
--- a/docs/components/014-image.md
+++ b/docs/components/014-image.md
@@ -29,7 +29,7 @@ view := func(c *ui.Context) {
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
-| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时使用 `src` 的尺寸(只给一边时按比例补齐),在占位、加载、错误状态下也保持这个尺寸;`src` 为 nil 时为 160×120。负数 panic。 |
+| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时使用 `src` 的尺寸(只给一边时按比例补齐);`src` 为 nil 时为 160×120。占位、加载、错误状态与已加载图片使用相同的宽高比和伸缩规则,父容器变窄时一起按比例缩小,所以各状态的盒子完全相同。负数 panic。 |
| `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 |
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
| `Loading` | `bool` | `false` | 调用方正在加载。 |
@@ -43,7 +43,7 @@ view := func(c *ui.Context) {
- 占位:浅色框内显示图片图标,名称“暂无图片”。
- 加载中:框内显示 Spinner,名称“正在加载图片”。
-- 错误:显示错误图标、“图片加载失败”和“重试”按钮,`Err.Error()` 作为无障碍错误说明。
+- 错误:盒子中央是 28×28 DIP 的仅图标“重试”按钮(`refresh-cw`),`Err.Error()` 作为按钮的悬停提示和描述,也是盒子的无障碍错误说明。盒子足够宽时,按钮旁边还会显示“图片加载失败”文字。
## 事件
@@ -55,6 +55,9 @@ view := func(c *ui.Context) {
## 限制
+- 盒子高度至少 28 DIP 时,“重试”按钮才能完整显示。更矮的盒子会裁掉按钮的上下边缘,但按钮中心仍然可以点击,键盘也可以操作。
+- “图片加载失败”文字只在盒子宽度足够时显示。宽度按上一帧的布局判断,盒子尺寸变化时组件会多布局一帧来修正。
+
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
diff --git a/examples/gallery/pages_display.go b/examples/gallery/pages_display.go
index 596ce4b..2f11f00 100644
--- a/examples/gallery/pages_display.go
+++ b/examples/gallery/pages_display.go
@@ -1,7 +1,6 @@
package main
import (
- "errors"
"fmt"
"strings"
@@ -172,116 +171,6 @@ const iconCode = `ui.Row(c).Gap(8).Children(func() {
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
})`
-type imageState struct {
- crop, loading, fail, empty bool
- err error
-}
-
-var displayPicture = func() *ui.SVG {
- return ui.MustParseSVG([]byte(``))
-}()
-
-func imageDemo(c *ui.Context, log func(string)) {
- s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
- mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
- displaySection(c, "Parameters", func() {
- displayControls(c, func() {
- displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
- displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
- displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
- if ui.Checkbox(c, &s.fail, "Error").Changed() {
- log(fmt.Sprintf("fail = %v", s.fail))
- s.err = nil
- if s.fail {
- s.err = errors.New("connection reset")
- }
- }
- })
- })
- displaySection(c, "Preview", func() {
- var src ui.ImageSource = displayPicture
- if s.empty {
- src = nil
- }
- v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
- if v.Retry {
- s.fail, s.err = false, nil
- log("retry")
- }
- })
- displaySection(c, "States", func() {
- ui.Row(c).Gap(12).Wrap().Children(func() {
- mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
- mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
- mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
- mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
- if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
- log("retry (static example)")
- }
- })
- })
- displayCode(c, imageCode, log)
-}
-
-const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
- Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
- Loading: loading, Err: loadErr,
-})
-if img.Retry {
- loading, loadErr = true, nil // start loading again here
-}`
-
-type avatarState struct {
- name string
- size float64
- img bool
-}
-
-var displayBitmap = func() *ui.Bitmap {
- return ui.NewBitmap(ui.Render(func(c *ui.Context) {
- mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
- ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
- }, 64, 64, 1))
-}()
-
-func avatarDemo(c *ui.Context, log func(string)) {
- s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
- mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
- displaySection(c, "Parameters", func() {
- displayControls(c, func() {
- if ui.TextInput(c, &s.name).Width(200).Changed() {
- log("name " + s.name)
- }
- ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
- displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
- displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
- })
- })
- displaySection(c, "Preview", func() {
- var img *ui.Bitmap
- if s.img {
- img = displayBitmap
- }
- mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
- })
- displaySection(c, "Fallbacks", func() {
- ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
- mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
- mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
- mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
- mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
- })
- })
- displayCode(c, avatarCode, log)
-}
-
-const avatarCode = `ui.Row(c).Gap(8).Children(func() {
- mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
- mujica.Avatar(c, "", mujica.AvatarOptions{})
-})`
-
var displayPeople = []mujica.AvatarItem{
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
@@ -444,6 +333,7 @@ func linkDemo(c *ui.Context, log func(string)) {
}
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
s.external = ""
+ log("open failure cleared")
}
})
})
diff --git a/examples/gallery/pages_display_media.go b/examples/gallery/pages_display_media.go
new file mode 100644
index 0000000..945b929
--- /dev/null
+++ b/examples/gallery/pages_display_media.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/egoist/mygo/ui"
+
+ mujica "mujicaui"
+)
+
+type imageState struct {
+ crop, loading, fail, empty bool
+ err error
+}
+
+var displayPicture = func() *ui.SVG {
+ return ui.MustParseSVG([]byte(``))
+}()
+
+func imageDemo(c *ui.Context, log func(string)) {
+ s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
+ mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
+ displaySection(c, "Parameters", func() {
+ displayControls(c, func() {
+ displayLog(log, ui.Checkbox(c, &s.crop, "Crop"), "crop", &s.crop)
+ displayLog(log, ui.Checkbox(c, &s.empty, "No source"), "empty", &s.empty)
+ displayLog(log, ui.Checkbox(c, &s.loading, "Loading"), "loading", &s.loading)
+ if ui.Checkbox(c, &s.fail, "Error").Changed() {
+ log(fmt.Sprintf("fail = %v", s.fail))
+ s.err = nil
+ if s.fail {
+ s.err = errors.New("connection reset")
+ }
+ }
+ })
+ })
+ displaySection(c, "Preview", func() {
+ var src ui.ImageSource = displayPicture
+ if s.empty {
+ src = nil
+ }
+ v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
+ if v.Retry {
+ s.fail, s.err = false, nil
+ log("retry")
+ }
+ })
+ displaySection(c, "States", func() {
+ ui.Row(c).Gap(12).Wrap().Children(func() {
+ mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
+ mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
+ mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
+ mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
+ if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
+ log("retry (static example)")
+ }
+ })
+ })
+ displayCode(c, imageCode, log)
+}
+
+const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
+ Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
+ Loading: loading, Err: loadErr,
+})
+if img.Retry {
+ loading, loadErr = true, nil // start loading again here
+}`
+
+type avatarState struct {
+ name string
+ size float64
+ img bool
+}
+
+var displayBitmap = func() *ui.Bitmap {
+ return ui.NewBitmap(ui.Render(func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
+ ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
+ }, 64, 64, 1))
+}()
+
+func avatarDemo(c *ui.Context, log func(string)) {
+ s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
+ mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
+ displaySection(c, "Parameters", func() {
+ displayControls(c, func() {
+ if ui.TextInput(c, &s.name).Width(200).Changed() {
+ log("name " + s.name)
+ }
+ ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
+ displayLog(log, ui.StepSlider(c, &s.size, 24, 80, 4).Width(160), "size", &s.size)
+ displayLog(log, ui.Checkbox(c, &s.img, "Image"), "img", &s.img)
+ })
+ })
+ displaySection(c, "Preview", func() {
+ var img *ui.Bitmap
+ if s.img {
+ img = displayBitmap
+ }
+ mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
+ })
+ displaySection(c, "Fallbacks", func() {
+ ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
+ mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
+ mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
+ mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
+ mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
+ })
+ })
+ displayCode(c, avatarCode, log)
+}
+
+const avatarCode = `ui.Row(c).Gap(8).Children(func() {
+ mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
+ mujica.Avatar(c, "", mujica.AvatarOptions{})
+})`
diff --git a/image.go b/image.go
index 50481e3..7dfc000 100644
--- a/image.go
+++ b/image.go
@@ -5,6 +5,7 @@ import (
"github.com/egoist/mygo/ui"
+ "mujicaui/internal"
"mujicaui/theme"
)
@@ -54,22 +55,36 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
u := Density(c).Unit()
w, h := imageBox(src, opts.Width, opts.Height)
var v ImageView
- frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip().
+ // Same size rules as the loaded image.
+ frame := ui.Row(c).AspectRatio(w/h).Center().Gap(u*2).Padding(0, u).Clip().
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
+ if opts.Width > 0 || opts.Height == 0 {
+ frame.Width(w)
+ }
+ if opts.Height > 0 {
+ frame.Height(h)
+ }
v.Element = frame
+ room := frame.Bounds()
+ // Build again when the box changed, settling the error text.
+ if last := ui.Local(frame, imageRoomKey{}, func() ui.Rect { return ui.Rect{W: -1} }); *last != room {
+ *last = room
+ c.AnimationFrame()
+ }
frame.Children(func() {
switch {
case opts.Err != nil:
frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
- Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger})
- ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).TextAlign(ui.Center)
- if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() {
+ if imageRetry(c, k, opts.Err.Error()).Clicked() {
v.Retry = true
slog.Debug("mujica: image retry", "alt", opts.Alt)
}
+ if room.W >= theme.MinHitSize+u*4+imageErrorTextWidth {
+ ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
+ }
case opts.Loading:
frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
- Spinner(c, SpinnerOptions{Size: 20, Label: msg(c, "image.loading")})
+ Spinner(c, SpinnerOptions{Size: min(20, room.H), Label: msg(c, "image.loading")})
default:
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted})
@@ -78,6 +93,28 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
return v
}
+type imageRoomKey struct{}
+
+// imageErrorTextWidth is the room the error text needs.
+const imageErrorTextWidth = 120
+
+// imageRetry is the 28 DIP icon-only retry button.
+func imageRetry(c *ui.Context, k theme.Tokens, problem string) *ui.Element {
+ b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).
+ Label(msg(c, "image.retry")).Description(problem).Tooltip(problem)
+ bg := k.Surface
+ if b.Pressed() {
+ bg = k.Selection
+ } else if b.Hovered() {
+ bg = k.SurfaceHover
+ }
+ b.Background(bg).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Danger)
+ internal.Ring(b, k.Focus, theme.ControlRadius)
+ internal.Fade(b, Motion(c, theme.StateDuration))
+ b.Children(func() { Icon(c, "refresh-cw", IconOptions{Size: 16}) })
+ return b
+}
+
// imageBox fills unset sides from src's aspect, else 160×120.
func imageBox(src ui.ImageSource, w, h float32) (float32, float32) {
var sw, sh float32
diff --git a/image_state_test.go b/image_state_test.go
new file mode 100644
index 0000000..75a800c
--- /dev/null
+++ b/image_state_test.go
@@ -0,0 +1,35 @@
+package mujica
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestImageErrorTextNeedsRoom(t *testing.T) {
+ for _, tc := range []struct {
+ w float32
+ text bool
+ }{{200, true}, {80, false}} {
+ ready := false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if ready {
+ Image(c, nil, ImageOptions{Width: tc.w, Height: 60, Err: errors.New("404")})
+ }
+ }, 300, 200)
+ ready = true
+ tt.Frame()
+ n := 0
+ for _, s := range tt.Texts() {
+ if s == "Image failed to load" {
+ n++
+ }
+ }
+ // One is the box's name; a second is the visible text.
+ if got := n == 2; got != tc.text {
+ t.Errorf("width %v: error text shown %v, want %v (%v)", tc.w, got, tc.text, tt.Texts())
+ }
+ }
+}
diff --git a/image_test.go b/image_test.go
index 7316b7b..657c173 100644
--- a/image_test.go
+++ b/image_test.go
@@ -77,11 +77,14 @@ func TestImageRetry(t *testing.T) {
retries++
}
}, 300, 300)
+ tt.Key(0, ui.KeyTab)
+ if !tt.Focused("Retry") {
+ t.Fatal("retry not focused by Tab")
+ }
+ tt.Key(0, ui.KeyEnter)
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
- tt.Key(0, ui.KeyTab)
- tt.Key(0, ui.KeyEnter)
if retries != 2 {
t.Fatalf("retries %d, want 2", retries)
}
@@ -102,12 +105,13 @@ func TestImageNegativeSizePanics(t *testing.T) {
func TestImageSnapshot(t *testing.T) {
bmp := imageTestBitmap()
- snapshot(t, "image", 600, 180, func(c *ui.Context) {
+ snapshot(t, "image", 760, 170, func(c *ui.Context) {
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"})
Image(c, nil, ImageOptions{Width: 120, Height: 120})
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
- Image(c, nil, ImageOptions{Width: 160, Height: 140, Err: errors.New("404")})
+ Image(c, nil, ImageOptions{Width: 200, Height: 120, Err: errors.New("404")})
+ Image(c, bmp, ImageOptions{Err: errors.New("404")})
})
})
}
@@ -135,3 +139,41 @@ func TestImageKeepsSizeWhileLoading(t *testing.T) {
}
}
}
+
+func TestImageStatesShareBoxWhenShrunk(t *testing.T) {
+ bmp := imageTestBitmap()
+ var got []ui.Rect
+ for _, opts := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
+ var r ui.Rect
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
+ r = Image(c, bmp, opts).Element.Bounds()
+ })
+ }, 300, 300)
+ tt.Frame()
+ got = append(got, r)
+ }
+ if got[0].W != 40 || got[0].H != 20 || got[1] != got[0] || got[2] != got[0] {
+ t.Fatalf("loaded, loading, error boxes %v", got)
+ }
+}
+
+func TestImageRetryInSmallBox(t *testing.T) {
+ retries := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
+ if Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")}).Retry {
+ retries++
+ }
+ })
+ }, 300, 300)
+ tt.Frame()
+ if err := tt.Click("Retry"); err != nil {
+ t.Fatal(err)
+ }
+ if retries != 1 {
+ t.Fatalf("retries %d, want 1", retries)
+ }
+}
diff --git a/testdata/snapshots/darwin/image-dark.png b/testdata/snapshots/darwin/image-dark.png
index 286963c..dd44a88 100644
Binary files a/testdata/snapshots/darwin/image-dark.png and b/testdata/snapshots/darwin/image-dark.png differ
diff --git a/testdata/snapshots/darwin/image-light.png b/testdata/snapshots/darwin/image-light.png
index 9b207d6..10ab9f5 100644
Binary files a/testdata/snapshots/darwin/image-light.png and b/testdata/snapshots/darwin/image-light.png differ
diff --git a/text.go b/text.go
index e275700..e2aa6ca 100644
--- a/text.go
+++ b/text.go
@@ -120,6 +120,7 @@ func editableTextField(c *ui.Context, k theme.Tokens, value *string, opts Editab
in = ui.TextInputBase(c, &st.draft).Grow(1).MinWidth(theme.MinHitSize*3).Padding(pad, pad*2).
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text).
Label(opts.Label).Error(opts.Error)
+ internal.Ring(in, k.Focus, theme.ControlRadius)
})
done := func() {
st.editing = false
diff --git a/text_test.go b/text_test.go
index 8866fb9..d6ead0c 100644
--- a/text_test.go
+++ b/text_test.go
@@ -4,6 +4,8 @@ import (
"testing"
"github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
)
func TestTextSelectAndCopy(t *testing.T) {
@@ -183,3 +185,34 @@ func TestTextSnapshot(t *testing.T) {
})
})
}
+
+func TestEditableTextInputFocusRing(t *testing.T) {
+ name := "report.pdf"
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{Mode: Light})
+ ui.Column(c).Padding(20).Gap(20).AlignItems(ui.Start).Children(func() {
+ EditableText(c, &name, EditableTextOptions{Label: "File name"})
+ Button(c, "Other", ButtonOptions{})
+ })
+ }, 300, 160)
+ tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ r, ok := tt.Find("File name")
+ if !ok || !tt.Focused("File name") {
+ t.Fatal("editing input not found or not focused")
+ }
+ // The ring's middle: focus gap plus half its width outside the box.
+ x, y := int(r.X-theme.FocusGap-theme.FocusWidth/2), int(r.Y+r.H/2)
+ focused := tt.Image().RGBAAt(x, y)
+ tt.Key(0, ui.KeyEscape)
+ tt.Key(0, ui.KeyTab)
+ if tt.Focused("File name") {
+ t.Fatal("focus did not leave")
+ }
+ plain := tt.Image().RGBAAt(x, y)
+ want := theme.Light().Focus
+ if d := int(focused.R) - int(want.R); d < -24 || d > 24 || focused == plain {
+ t.Fatalf("ring pixel %v focused, %v unfocused, want near %v", focused, plain, want)
+ }
+}