From 9186dd67a3d42228971b968d9ae9e17e680c9582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 12:42:29 -0400 Subject: [PATCH] Display: review r2 fixes --- docs/components/014-image.md | 7 +- examples/gallery/pages_display.go | 112 +------------------- examples/gallery/pages_display_media.go | 120 ++++++++++++++++++++++ image.go | 47 ++++++++- image_state_test.go | 35 +++++++ image_test.go | 50 ++++++++- testdata/snapshots/darwin/image-dark.png | Bin 7050 -> 6980 bytes testdata/snapshots/darwin/image-light.png | Bin 6942 -> 7028 bytes text.go | 1 + text_test.go | 33 ++++++ 10 files changed, 283 insertions(+), 122 deletions(-) create mode 100644 examples/gallery/pages_display_media.go create mode 100644 image_state_test.go 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 286963c4894e311f1315462bd2fd54a7e22c95a3..dd44a888a87feaeed2a1de457124609bc7570f89 100644 GIT binary patch literal 6980 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 I&)kuena3oU|XnD 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 Fe**}EN`n9Z diff --git a/testdata/snapshots/darwin/image-light.png b/testdata/snapshots/darwin/image-light.png index 9b207d68a367f4f5b462842b9d58c180b428b8b1..10ab9f5c4e4f4acf4748c3ade23b3d4667cc05d9 100644 GIT binary patch literal 7028 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 Sf2q@N{!o-tlP#Bi75HCaXdK=E literal 6942 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 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) + } +}