Display: review r3 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:59:42 -04:00
1 parent cfdccfec5c
commit e9226512ad
6 files changed
+197 -89

No files matched your search

+2 -1
View File
@@ -43,6 +43,7 @@ view := func(c *ui.Context) {
- 占位:浅色框内显示图片图标,名称“暂无图片”。
- 加载中:框内显示 Spinner,名称“正在加载图片”。
- 各状态共用同一个外层盒子:图片元素始终参与布局(非加载状态下隐藏),所以在默认拉伸的 Column、Row 和固定尺寸的父容器里,盒子大小完全相同。
- 错误:盒子中央是 28×28 DIP 的仅图标“重试”按钮(`refresh-cw`),`Err.Error()` 作为按钮的悬停提示和描述,也是盒子的无障碍错误说明。盒子足够宽时,按钮旁边还会显示“图片加载失败”文字。
## 事件
@@ -55,7 +56,7 @@ view := func(c *ui.Context) {
## 限制
- 盒子高度至少 28 DIP 时,“重试”按钮才能完整显示。更矮的盒子会裁掉按钮的上下边缘,但按钮中心仍然可以点击,键盘也可以操作。
- “重试”按钮画在不裁切的图层上,以盒子为中心,始终保持 28×28 DIP 的点击区域和完整的焦点环。盒子矮于 28 DIP 时,按钮会超出盒子的上下边缘,可能盖住相邻内容;需要按钮完全落在盒子内时,请让盒子至少 28 DIP 高。
- “图片加载失败”文字只在盒子宽度足够时显示。宽度按上一帧的布局判断,盒子尺寸变化时组件会多布局一帧来修正。
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
+86 -87
View File
@@ -30,77 +30,100 @@ type ImageView struct {
Retry bool
}
// Image shows a bitmap or SVG; nil shows a placeholder.
func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
if opts.Width < 0 || opts.Height < 0 {
panic("mujica: Image Width and Height must not be negative")
}
if opts.Err == nil && !opts.Loading && src != nil {
e := ui.Image(c, src).Radius(theme.ControlRadius)
if opts.Crop {
e.Fit(ui.Cover)
}
if opts.Width > 0 {
e.Width(opts.Width)
}
if opts.Height > 0 {
e.Height(opts.Height)
}
if opts.Alt != "" {
e.Label(opts.Alt)
}
return ImageView{Element: e}
}
k := Tokens(c)
u := Density(c).Unit()
w, h := imageBox(src, opts.Width, opts.Height)
var v ImageView
// 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())
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: 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})
}
})
return v
}
// imageBlank sizes the box of a nil src: 160×120.
var imageBlank = ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120"/>`))
type imageRoomKey struct{}
// imageErrorTextWidth is the room the error text needs.
const imageErrorTextWidth = 120
// Image shows a bitmap or SVG; nil shows a placeholder.
func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
if opts.Width < 0 || opts.Height < 0 {
panic("mujica: Image Width and Height must not be negative")
}
loaded := opts.Err == nil && !opts.Loading && src != nil
var v ImageView
v.Element = imageSized(c, src, opts, func(box, pic *ui.Element) {
if loaded {
if opts.Alt != "" {
pic.Label(opts.Alt)
}
return
}
pic.Invisible()
v.Retry = imageLayers(c, box, opts)
})
return v
}
// imageLayers builds the state layers over the hidden picture.
func imageLayers(c *ui.Context, box *ui.Element, opts ImageOptions) (retry bool) {
k := Tokens(c)
u := Density(c).Unit()
room := box.Bounds()
// Build again when the box changed, settling the error text.
if last := ui.Local(box, imageRoomKey{}, func() ui.Rect { return ui.Rect{W: -1} }); *last != room {
*last = room
c.AnimationFrame()
}
{
// The clipped backdrop; Retry sits above it, unclipped.
back := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Clip().
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
switch {
case opts.Err != nil:
box.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
ui.Row(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Gap(u * 2).Children(func() {
if imageRetry(c, k, opts.Err.Error()).Clicked() {
retry = true
slog.Debug("mujica: image retry", "alt", opts.Alt)
}
if room.W >= theme.MinHitSize+u*4+imageErrorTextWidth && room.H >= theme.MinHitSize {
ui.Text(c, msg(c, "image.error")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
}
})
case opts.Loading:
box.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
back.Children(func() {
Spinner(c, SpinnerOptions{Size: max(8, min(20, room.H-4)), Label: msg(c, "image.loading")})
})
default:
box.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
back.Children(func() { Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted}) })
}
}
return retry
}
// imageSized builds the outer box and its picture, sized alike in every state.
func imageSized(c *ui.Context, src ui.ImageSource, opts ImageOptions, layers func(box, pic *ui.Element)) *ui.Element {
if src == nil {
src = imageBlank
}
box := ui.Box(c)
box.Children(func() {
pic := ui.Image(c, src).Radius(theme.ControlRadius)
if opts.Crop {
pic.Fit(ui.Cover)
}
if opts.Width > 0 {
box.Width(opts.Width)
pic.Width(opts.Width)
}
if opts.Height > 0 {
box.Height(opts.Height)
pic.Height(opts.Height)
}
layers(box, pic)
})
return box
}
// 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).
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius).
Label(msg(c, "image.retry")).Description(problem).Tooltip(problem)
bg := k.Surface
if b.Pressed() {
@@ -114,27 +137,3 @@ func imageRetry(c *ui.Context, k theme.Tokens, problem string) *ui.Element {
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
switch s := src.(type) {
case *ui.Bitmap:
iw, ih := s.Size()
sw, sh = float32(iw), float32(ih)
case *ui.SVG:
sw, sh = s.Size()
}
if sw <= 0 || sh <= 0 {
sw, sh = 160, 120
}
switch {
case w == 0 && h == 0:
return sw, sh
case w == 0:
return h * sw / sh, h
case h == 0:
return w, w * sh / sw
}
return w, h
}
+105
View File
@@ -5,6 +5,8 @@ import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestImageErrorTextNeedsRoom(t *testing.T) {
@@ -33,3 +35,106 @@ func TestImageErrorTextNeedsRoom(t *testing.T) {
}
}
}
// imageStateBoxes returns the box of loaded, loading and error states.
func imageStateBoxes(t *testing.T, opts ImageOptions, parent func(c *ui.Context, fn func())) []ui.Rect {
t.Helper()
bmp := imageTestBitmap()
var got []ui.Rect
for _, state := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
o := opts
o.Loading, o.Err = state.Loading, state.Err
var r ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
parent(c, func() { r = Image(c, bmp, o).Element.Bounds() })
}, 400, 400)
tt.Frame()
got = append(got, r)
}
return got
}
func TestImageStatesShareBoxInColumn(t *testing.T) {
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
ui.Column(c).Width(200).Children(fn)
})
if got[0].W != 200 || got[0].H != 100 || got[1] != got[0] || got[2] != got[0] {
t.Fatalf("loaded, loading, error boxes %v", got)
}
}
func TestImageStatesShareBoxInSizedParent(t *testing.T) {
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
ui.Column(c).Size(120, 60).Children(fn)
})
if got[0].W != 120 || got[1] != got[0] || got[2] != got[0] {
t.Fatalf("loaded, loading, error boxes %v", got)
}
}
func TestImageStatesShareExplicitSize(t *testing.T) {
got := imageStateBoxes(t, ImageOptions{Width: 120, Height: 60}, func(c *ui.Context, fn func()) {
ui.Column(c).Width(300).Children(fn)
})
if got[0].W != 120 || got[0].H != 60 || got[1] != got[0] || got[2] != got[0] {
t.Fatalf("loaded, loading, error boxes %v", got)
}
}
func TestImageStatesShareBoxInNarrowRow(t *testing.T) {
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
ui.Row(c).Width(40).AlignItems(ui.Start).Children(fn)
})
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 TestImageRetryUnclippedInSmallBox(t *testing.T) {
retries := 0
var box ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Row(c).Padding(40).AlignItems(ui.Start).Children(func() {
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
v := Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")})
box = v.Element.Bounds()
if v.Retry {
retries++
}
})
})
}, 300, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if box.W != 40 || box.H != 20 {
t.Fatalf("box %v", box)
}
r, ok := tt.Find("Retry")
if !ok || r.W != 28 || r.H != 28 {
t.Fatalf("retry bounds %v", r)
}
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
if retries != 1 {
t.Fatalf("retries %d, want 1", retries)
}
cx := int(r.X + r.W/2)
ring := func() (above, below ui.Color) {
img := tt.Image()
a := img.RGBAAt(cx, int(r.Y-theme.FocusGap-theme.FocusWidth/2))
b := img.RGBAAt(cx, int(r.Y+r.H+theme.FocusGap+theme.FocusWidth/2))
return ui.Color{R: a.R, G: a.G, B: a.B, A: a.A}, ui.Color{R: b.R, G: b.G, B: b.B, A: b.A}
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Retry") {
t.Fatal("retry not focused")
}
above, below := ring()
want := theme.Light().Focus
if above != want || below != want {
t.Fatalf("ring above %v below %v, want %v", above, below, want)
}
if r.Y-theme.FocusGap >= box.Y || r.Y+r.H+theme.FocusGap <= box.Y+box.H {
t.Fatalf("ring rows not outside box %v (retry %v)", box, r)
}
}
+4 -1
View File
@@ -105,13 +105,16 @@ func TestImageNegativeSizePanics(t *testing.T) {
func TestImageSnapshot(t *testing.T) {
bmp := imageTestBitmap()
snapshot(t, "image", 760, 170, func(c *ui.Context) {
snapshot(t, "image", 820, 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: 200, Height: 120, Err: errors.New("404")})
Image(c, bmp, ImageOptions{Err: errors.New("404")})
ui.Row(c).Width(40).AlignItems(ui.Start).Margin(20, 0, 0, 0).Children(func() {
Image(c, bmp, ImageOptions{Err: errors.New("404")})
})
})
})
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB