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 286963c..dd44a88 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 9b207d6..10ab9f5 100644 Binary files a/testdata/snapshots/darwin/image-light.png and b/testdata/snapshots/darwin/image-light.png differ 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) + } +}