Display: review r2 fixes
This commit is contained in:
10 files changed
+283
-122
No files matched your search
@@ -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,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")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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{})
|
||||
})`
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.8 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user