diff --git a/docs/components/014-image.md b/docs/components/014-image.md index f730439..37cf062 100644 --- a/docs/components/014-image.md +++ b/docs/components/014-image.md @@ -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`。 diff --git a/image.go b/image.go index 7dfc000..83d5682 100644 --- a/image.go +++ b/image.go @@ -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(``)) 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 -} diff --git a/image_state_test.go b/image_state_test.go index 75a800c..4c26d16 100644 --- a/image_state_test.go +++ b/image_state_test.go @@ -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) + } +} diff --git a/image_test.go b/image_test.go index 657c173..32318be 100644 --- a/image_test.go +++ b/image_test.go @@ -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")}) + }) }) }) } diff --git a/testdata/snapshots/darwin/image-dark.png b/testdata/snapshots/darwin/image-dark.png index dd44a88..2b4ba73 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 10ab9f5..8168cdf 100644 Binary files a/testdata/snapshots/darwin/image-light.png and b/testdata/snapshots/darwin/image-light.png differ