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`)。 |
|
| `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 |
|
||||||
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
|
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
|
||||||
| `Loading` | `bool` | `false` | 调用方正在加载。 |
|
| `Loading` | `bool` | `false` | 调用方正在加载。 |
|
||||||
@@ -43,7 +43,7 @@ view := func(c *ui.Context) {
|
|||||||
|
|
||||||
- 占位:浅色框内显示图片图标,名称“暂无图片”。
|
- 占位:浅色框内显示图片图标,名称“暂无图片”。
|
||||||
- 加载中:框内显示 Spinner,名称“正在加载图片”。
|
- 加载中:框内显示 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`。
|
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
|
||||||
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
|
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
|
||||||
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
|
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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"})
|
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{
|
var displayPeople = []mujica.AvatarItem{
|
||||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
|
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
|
||||||
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
|
{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() {
|
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||||
s.external = ""
|
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"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
"mujicaui/theme"
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,22 +55,36 @@ func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
|
|||||||
u := Density(c).Unit()
|
u := Density(c).Unit()
|
||||||
w, h := imageBox(src, opts.Width, opts.Height)
|
w, h := imageBox(src, opts.Width, opts.Height)
|
||||||
var v ImageView
|
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)
|
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
|
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() {
|
frame.Children(func() {
|
||||||
switch {
|
switch {
|
||||||
case opts.Err != nil:
|
case opts.Err != nil:
|
||||||
frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
|
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})
|
if imageRetry(c, k, opts.Err.Error()).Clicked() {
|
||||||
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() {
|
|
||||||
v.Retry = true
|
v.Retry = true
|
||||||
slog.Debug("mujica: image retry", "alt", opts.Alt)
|
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:
|
case opts.Loading:
|
||||||
frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
|
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:
|
default:
|
||||||
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
|
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
|
||||||
Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted})
|
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
|
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.
|
// imageBox fills unset sides from src's aspect, else 160×120.
|
||||||
func imageBox(src ui.ImageSource, w, h float32) (float32, float32) {
|
func imageBox(src ui.ImageSource, w, h float32) (float32, float32) {
|
||||||
var sw, sh 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++
|
retries++
|
||||||
}
|
}
|
||||||
}, 300, 300)
|
}, 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 {
|
if err := tt.Click("Retry"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tt.Key(0, ui.KeyTab)
|
|
||||||
tt.Key(0, ui.KeyEnter)
|
|
||||||
if retries != 2 {
|
if retries != 2 {
|
||||||
t.Fatalf("retries %d, want 2", retries)
|
t.Fatalf("retries %d, want 2", retries)
|
||||||
}
|
}
|
||||||
@@ -102,12 +105,13 @@ func TestImageNegativeSizePanics(t *testing.T) {
|
|||||||
|
|
||||||
func TestImageSnapshot(t *testing.T) {
|
func TestImageSnapshot(t *testing.T) {
|
||||||
bmp := imageTestBitmap()
|
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() {
|
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, 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})
|
||||||
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
|
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).
|
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).
|
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text).
|
||||||
Label(opts.Label).Error(opts.Error)
|
Label(opts.Label).Error(opts.Error)
|
||||||
|
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||||
})
|
})
|
||||||
done := func() {
|
done := func() {
|
||||||
st.editing = false
|
st.editing = false
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTextSelectAndCopy(t *testing.T) {
|
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