Display: review r3 fixes
This commit is contained in:
6 files changed
+197
-89
No files matched your search
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 7.5 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.4 KiB |
Reference in new issue
Block a user