Display: review r2 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:42:29 -04:00
1 parent a27727bc71
commit 9186dd67a3
10 files changed
+283 -122

No files matched your search

+5 -2
View File
@@ -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`,不会显示占位。
+1 -111
View File
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
}()
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")
}
})
})
+120
View File
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
}()
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{})
})`
+42 -5
View File
@@ -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
+35
View File
@@ -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())
}
}
}
+46 -4
View File
@@ -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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

+1
View File
@@ -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
+33
View File
@@ -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)
}
}