Feedback: review r1 fixes
This commit is contained in:
18 files changed
+250
-83
No files matched your search
@@ -43,8 +43,10 @@ func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
|
||||
return k.Warning, icons.Must("triangle-alert")
|
||||
case SeverityError:
|
||||
return k.Danger, icons.Must("circle-x")
|
||||
case SeverityInfo:
|
||||
return k.Info, icons.Must("info")
|
||||
}
|
||||
return k.Info, icons.Must("info")
|
||||
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
|
||||
}
|
||||
|
||||
// AlertOptions configure an Alert.
|
||||
@@ -66,9 +68,7 @@ type AlertResult struct {
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Alert shows an inline message marked by color, icon and accessible name.
|
||||
// A non-nil open adds a dismiss button that sets *open to false; nothing
|
||||
// shows while *open is false.
|
||||
// Alert shows an inline message; a non-nil open adds a dismiss button that clears it.
|
||||
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
|
||||
if message == "" {
|
||||
panic("mujica: Alert needs a message")
|
||||
|
||||
@@ -27,18 +27,18 @@ mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 |
|
||||
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 |
|
||||
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`;其他值 panic。 |
|
||||
| `Indeterminate` | `bool` | `false` | 未知长度的进度。 |
|
||||
| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 |
|
||||
| `ShowValue` | `bool` | `false` | 显示百分比。 |
|
||||
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 |
|
||||
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数、NaN 或无穷 panic。 |
|
||||
|
||||
`MeterOptions`:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `float64` | 必填 | 须在范围内,否则 panic。 |
|
||||
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;`Max <= Min` panic。 |
|
||||
| `Min`, `Max` | `float64` | 0, 1(两者都为 0 时) | 范围;非有限值或 `Max <= Min` panic。 |
|
||||
| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
|
||||
| `Label` | `string` | `""` | 名称。 |
|
||||
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
|
||||
@@ -47,7 +47,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
## 状态
|
||||
|
||||
- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。
|
||||
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。
|
||||
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示满格静态填充(对比度不低于 3:1)和“In progress”文字;切换到减少动画时进行中的过渡直接跳到目标值。
|
||||
- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -16,7 +16,7 @@ mujica.Spinner(c, mujica.SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: t
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Size` | `float32` | 16 | 标记边长(DIP)。 |
|
||||
| `Size` | `float32` | 16 | 标记边长(DIP);负数、NaN 或无穷 panic。 |
|
||||
| `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 |
|
||||
| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 |
|
||||
| `Color` | `ui.Color` | `AccentText` | 标记颜色。 |
|
||||
|
||||
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
|
||||
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
|
||||
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
|
||||
|
||||
负值 panic。
|
||||
负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。
|
||||
|
||||
## 状态
|
||||
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult {
|
||||
if !opts.NoIcon {
|
||||
ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0)
|
||||
}
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text).TextAlign(ui.Center)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).TextAlign(ui.Center)
|
||||
ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight)
|
||||
if opts.Action != "" {
|
||||
ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestFeedbackTextFollowsTextScale(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}, {ID: "u", Title: "Unread one"}}
|
||||
var empty, result float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
Progress(c, 0.5, ProgressOptions{ShowValue: true})
|
||||
empty = EmptyState(c, EmptyStateOptions{Title: "Empty title", Description: "Short", NoIcon: true}).Element.Bounds().H
|
||||
result = Result(c, ResultOptions{Title: "Result title"}).Element.Bounds().H
|
||||
StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
}, 900, 2400)
|
||||
tt.Frame()
|
||||
empty1, result1 := empty, result
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame() // Bounds report the previous layout
|
||||
for text, min := range map[string]float32{
|
||||
"50%": theme.CaptionSize * 2,
|
||||
"Healthy": theme.BodySize * 2,
|
||||
"1 unread": theme.CaptionSize * 2,
|
||||
} {
|
||||
if r, ok := tt.Find(text); !ok || r.H < min {
|
||||
t.Errorf("%q at 200%%: %v %v, want height >= %v", text, r, ok, min)
|
||||
}
|
||||
}
|
||||
// Titles are found only through their labeled groups: the groups must grow by the titles too.
|
||||
if grow := empty - empty1; grow < theme.BodySize*theme.LineHeight+theme.H3Size {
|
||||
t.Errorf("EmptyState grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
if grow := result - result1; grow < theme.H2Size {
|
||||
t.Errorf("Result grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackRejectsInvalidInput(t *testing.T) {
|
||||
nan, inf := math.NaN(), math.Inf(1)
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"progress shape": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Shape: 7}) },
|
||||
"progress NaN size": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Size: float32(nan)}) },
|
||||
"meter infinite range": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: inf}) },
|
||||
"meter NaN min": func(c *ui.Context) { Meter(c, 1, MeterOptions{Min: nan, Max: 2}) },
|
||||
"meter NaN threshold": func(c *ui.Context) {
|
||||
Meter(c, 1, MeterOptions{Max: 2, Levels: &ui.MeterLevels{Warning: nan, Critical: 2}})
|
||||
},
|
||||
"skeleton shape": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Shape: 5}) },
|
||||
"skeleton NaN width": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Width: float32(nan)}) },
|
||||
"spinner negative size": func(c *ui.Context) { Spinner(c, SpinnerOptions{Size: -4}) },
|
||||
"status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) },
|
||||
"alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) },
|
||||
"toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationTitleOnlyHitHeight(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
}, 600, 400)
|
||||
if r, ok := tt.Find("Backup complete"); !ok || r.H < theme.MinHitSize {
|
||||
t.Fatalf("title-only notification hit box %v %v", r, ok)
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -25,9 +25,7 @@ type LoadingOverlayResult struct {
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
// LoadingOverlay builds content and, while loading, covers it with a
|
||||
// spinner and label. Covered content takes no pointer, keys or focus;
|
||||
// the rest of the window stays usable. Focus inside moves to the overlay.
|
||||
// LoadingOverlay covers content while loading, blocking input to that region only.
|
||||
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
|
||||
if content == nil {
|
||||
panic("mujica: LoadingOverlay needs content")
|
||||
|
||||
@@ -44,9 +44,7 @@ type NotificationCenterResult struct {
|
||||
ClearedAll bool
|
||||
}
|
||||
|
||||
// NotificationCenter lists *items and edits them in place: read, unread,
|
||||
// remove, clear. The list is one Tab stop; arrows move among its buttons
|
||||
// and Delete removes the focused notification.
|
||||
// NotificationCenter lists *items and edits them in place: read, unread, remove, clear.
|
||||
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
|
||||
if items == nil {
|
||||
panic("mujica: NotificationCenter needs items")
|
||||
@@ -70,9 +68,9 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC
|
||||
Role(ui.RoleGroup).Label(title).Clip()
|
||||
panel.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
|
||||
@@ -135,7 +133,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
|
||||
p.FillPath(internal.Diamond(r.X+r.W/2, r.Y+r.H/2, 8), k.AccentText)
|
||||
}
|
||||
})
|
||||
open := ui.ButtonBase(c).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
|
||||
open := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Grow(1).Shrink(1).Column().AlignItems(ui.Start).Justify(ui.Start).Gap(u).
|
||||
Radius(theme.ControlRadius).Label(name)
|
||||
internal.Ring(open, k.Focus, theme.ControlRadius)
|
||||
open.Children(func() {
|
||||
@@ -145,7 +143,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
|
||||
t.Bold()
|
||||
}
|
||||
if n.Time != "" {
|
||||
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
|
||||
ui.Text(c, n.Time).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
}
|
||||
})
|
||||
if n.Body != "" {
|
||||
|
||||
+21
-19
@@ -31,14 +31,16 @@ type ProgressOptions struct {
|
||||
Size float32
|
||||
}
|
||||
|
||||
// Progress shows value, from 0 to 1, of work done. It panics outside that range.
|
||||
// Indeterminate indicators move only while painted and hold still under reduced motion.
|
||||
// Progress shows work done, value 0 to 1; indeterminate ones hold still under reduced motion.
|
||||
func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
|
||||
if opts.Shape != ProgressLinear && opts.Shape != ProgressRing {
|
||||
panic(fmt.Sprintf("mujica: unknown ProgressShape %d", int(opts.Shape)))
|
||||
}
|
||||
if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) {
|
||||
panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value))
|
||||
}
|
||||
if opts.Size < 0 {
|
||||
panic("mujica: negative Progress Size")
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic(fmt.Sprintf("mujica: Progress Size %v invalid", opts.Size))
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
@@ -71,12 +73,12 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
|
||||
if opts.Label != "" || caption != "" {
|
||||
ui.Row(c).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
}
|
||||
bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border)
|
||||
shown := float32(value)
|
||||
if !opts.Indeterminate {
|
||||
if !opts.Indeterminate && !still {
|
||||
shown = bar.Animate("progress.value", shown, theme.StateDuration)
|
||||
}
|
||||
bar.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
@@ -84,7 +86,7 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
|
||||
case !opts.Indeterminate:
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius)
|
||||
case still:
|
||||
p.Fill(r, k.AccentText.Alpha(0.4), theme.ControlRadius)
|
||||
p.Fill(r, k.AccentText, theme.ControlRadius)
|
||||
default:
|
||||
p.AnimationFrame()
|
||||
phase := float32(p.Now().UnixMilli()%1400) / 1400
|
||||
@@ -108,7 +110,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
row.Children(func() {
|
||||
ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent)
|
||||
shown := float32(acc)
|
||||
if !opts.Indeterminate {
|
||||
if !opts.Indeterminate && !still {
|
||||
shown = ring.Animate("progress.value", shown, theme.StateDuration)
|
||||
}
|
||||
stroke := max(3, d/10)
|
||||
@@ -123,7 +125,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
p.StrokePath(progressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText)
|
||||
}
|
||||
case still:
|
||||
p.StrokePath(&track, stroke, k.AccentText.Alpha(0.4))
|
||||
p.StrokePath(&track, stroke, k.AccentText)
|
||||
default:
|
||||
p.AnimationFrame()
|
||||
start := float32(p.Now().UnixMilli()%1200) / 1200
|
||||
@@ -132,7 +134,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
})
|
||||
if caption != "" && d >= 44 && !opts.Indeterminate {
|
||||
ring.Children(func() {
|
||||
ui.Text(c, caption).FontSize(theme.CaptionSize).FontFeatures("tnum").TextColor(k.Text)
|
||||
ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
|
||||
})
|
||||
ring.Center()
|
||||
}
|
||||
@@ -142,7 +144,7 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
ui.Text(c, opts.Label).TextColor(k.Text)
|
||||
}
|
||||
if caption != "" && (d < 44 || opts.Indeterminate) {
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize)
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -171,8 +173,7 @@ func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path {
|
||||
type MeterOptions struct {
|
||||
// Min and Max bound the value; both zero mean 0 to 1.
|
||||
Min, Max float64
|
||||
// Levels mark the value High from Warning and Critical from Critical;
|
||||
// Critical below Warning flags low values instead. Nil marks none.
|
||||
// Levels are the High and Critical thresholds; Critical below Warning flags low values.
|
||||
Levels *ui.MeterLevels
|
||||
Label string
|
||||
ShowValue bool
|
||||
@@ -195,21 +196,20 @@ func meterLevel(v float64, l *ui.MeterLevels) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Meter shows a level within a range, as disk use. Thresholds show as
|
||||
// ticks and the level as a word as well as a color. It panics on a value outside the range.
|
||||
// Meter shows a level within a range with threshold ticks and a level word, not color alone.
|
||||
func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
|
||||
lo, hi := opts.Min, opts.Max
|
||||
if lo == 0 && hi == 0 {
|
||||
hi = 1
|
||||
}
|
||||
if !(hi > lo) {
|
||||
if !finite(lo) || !finite(hi) || !(hi > lo) {
|
||||
panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi))
|
||||
}
|
||||
if math.IsNaN(value) || value < lo || value > hi {
|
||||
panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, hi))
|
||||
}
|
||||
if l := opts.Levels; l != nil && (l.Warning < lo || l.Warning > hi || l.Critical < lo || l.Critical > hi) {
|
||||
panic("mujica: Meter levels outside the range")
|
||||
if l := opts.Levels; l != nil && !(l.Warning >= lo && l.Warning <= hi && l.Critical >= lo && l.Critical <= hi) {
|
||||
panic(fmt.Sprintf("mujica: Meter levels %+v outside %v..%v", *l, lo, hi))
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
@@ -246,7 +246,7 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
|
||||
if opts.Label != "" || caption != "" {
|
||||
ui.Row(c).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
}
|
||||
name := opts.Label
|
||||
@@ -270,3 +270,5 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
|
||||
@@ -5,8 +5,54 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestProgressReducedMotionJumpsMidTransition(t *testing.T) {
|
||||
v := 0.0
|
||||
var bar ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, v, ProgressOptions{}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
v = 1
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if got, want := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2), theme.Light().AccentText; got != want {
|
||||
t.Fatalf("fill at 95%% is %v, want %v at once", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func progressPixel(tt *ui.Tester, x, y float32) ui.Color {
|
||||
r, g, b, _ := tt.Image().At(int(x), int(y)).RGBA()
|
||||
return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
}
|
||||
|
||||
func TestProgressStaticFillContrast(t *testing.T) {
|
||||
for _, m := range []Mode{Light, Dark} {
|
||||
var bar ui.Rect
|
||||
var k theme.Tokens
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: m})
|
||||
k = Tokens(c)
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, 0, ProgressOptions{Indeterminate: true}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame() // Bounds report the previous layout
|
||||
fill := progressPixel(tt, bar.X+bar.W/2, bar.Y+bar.H-3) // the bar is the last row
|
||||
for _, against := range []ui.Color{k.Border, k.Background} {
|
||||
if cr := theme.Contrast(fill, against); cr < 3 {
|
||||
t.Errorf("%v: static fill %v vs %v is %.2f:1", m, fill, against, cr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressValueText(t *testing.T) {
|
||||
v := 0.424
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
|
||||
@@ -38,8 +38,7 @@ type ResultResult struct {
|
||||
Action int
|
||||
}
|
||||
|
||||
// Result reports the outcome of a finished task and offers next steps.
|
||||
// It only reports the chosen step; the caller navigates.
|
||||
// Result shows a task outcome and reports the chosen next step; the caller navigates.
|
||||
func Result(c *ui.Context, opts ResultOptions) ResultResult {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
@@ -61,7 +60,7 @@ func Result(c *ui.Context, opts ResultOptions) ResultResult {
|
||||
res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*3).Padding(u*10, u*6).Role(ui.RoleGroup).Label(title)
|
||||
res.Element.Children(func() {
|
||||
ui.Icon(c, icon).Size(48, 48).TextColor(tone)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H2Size).TextColor(k.Text).TextAlign(ui.Center)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text).TextAlign(ui.Center)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(420).LineHeight(theme.LineHeight)
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -28,11 +29,13 @@ type SkeletonOptions struct {
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Skeleton stands in for content that is loading. It pulses while painted
|
||||
// and shows a static fill under reduced motion.
|
||||
// Skeleton stands in for loading content; it pulses while painted, static under reduced motion.
|
||||
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
|
||||
if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 {
|
||||
panic("mujica: negative Skeleton size")
|
||||
if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect {
|
||||
panic(fmt.Sprintf("mujica: unknown SkeletonShape %d", int(opts.Shape)))
|
||||
}
|
||||
if !(opts.Width >= 0) || !(opts.Height >= 0) || opts.Lines < 0 || math.IsInf(float64(opts.Width+opts.Height), 1) {
|
||||
panic(fmt.Sprintf("mujica: Skeleton size %vx%v, %d lines invalid", opts.Width, opts.Height, opts.Lines))
|
||||
}
|
||||
k := Tokens(c)
|
||||
still := c.Preferences().ReduceMotion
|
||||
@@ -70,9 +73,10 @@ func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
|
||||
e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) })
|
||||
default:
|
||||
lines := max(opts.Lines, 1)
|
||||
line := theme.BodySize * theme.LineHeight
|
||||
size := FontSize(c, theme.BodySize)
|
||||
line := size * theme.LineHeight
|
||||
e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
bar := theme.BodySize * 0.8
|
||||
bar := size * 0.8
|
||||
for i := range lines {
|
||||
w := r.W
|
||||
if lines > 1 && i == lines-1 {
|
||||
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -18,12 +19,14 @@ type SpinnerOptions struct {
|
||||
Color ui.Color
|
||||
}
|
||||
|
||||
// Spinner draws four diamonds turning around a center while it shows.
|
||||
// It repaints only while painted: hidden, it stops. Reduced motion holds it still.
|
||||
// Spinner turns four diamonds only while painted; hidden or under reduced motion it stops.
|
||||
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic(fmt.Sprintf("mujica: Spinner Size %v invalid", opts.Size))
|
||||
}
|
||||
k := Tokens(c)
|
||||
size := opts.Size
|
||||
if size <= 0 {
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
label := opts.Label
|
||||
|
||||
+3
-2
@@ -44,11 +44,12 @@ type StatusIndicatorOptions struct {
|
||||
|
||||
// StatusIndicator shows a status by its own shape and a word, never by color alone.
|
||||
func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element {
|
||||
key := "status_indicator." + status.String() // panics on an unknown Status
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "status_indicator."+status.String())
|
||||
label = msg(c, key)
|
||||
}
|
||||
var tone ui.Color
|
||||
var icon *ui.SVG
|
||||
@@ -71,7 +72,7 @@ func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions)
|
||||
} else {
|
||||
ui.Icon(c, icon).Size(16, 16).TextColor(tone)
|
||||
}
|
||||
ui.Text(c, label).TextColor(k.Text).FontSize(theme.BodySize).SingleLine()
|
||||
ui.Text(c, label).TextColor(k.Text).FontSize(FontSize(c, theme.BodySize)).SingleLine()
|
||||
})
|
||||
return row
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -44,7 +44,10 @@ type toastItem struct {
|
||||
}
|
||||
|
||||
// toastQueue holds a window's toasts, visible ones first in order shown.
|
||||
type toastQueue struct{ items []toastItem }
|
||||
type toastQueue struct {
|
||||
items []toastItem
|
||||
wake time.Time // when Toaster last asked for a frame; zero for none
|
||||
}
|
||||
|
||||
type toastKey struct{}
|
||||
|
||||
@@ -64,6 +67,7 @@ func (q *toastQueue) push(m ToastMessage, now time.Time) {
|
||||
if m.Message == "" {
|
||||
panic("mujica: toast needs a Message")
|
||||
}
|
||||
_ = m.Severity.String() // panics on an unknown Severity
|
||||
if m.ID == "" {
|
||||
m.ID = m.Message
|
||||
}
|
||||
@@ -106,8 +110,8 @@ func (it *toastItem) elapsed(now time.Time) time.Duration {
|
||||
return it.spent + now.Sub(it.runFrom)
|
||||
}
|
||||
|
||||
// expire drops toasts whose time ran out and returns the wait until the next does.
|
||||
func (q *toastQueue) expire(now time.Time) (next time.Duration) {
|
||||
// expire drops toasts whose time ran out and shows queued ones.
|
||||
func (q *toastQueue) expire(now time.Time) {
|
||||
live := q.items[:0]
|
||||
for _, it := range q.items {
|
||||
if it.visible && it.life > 0 && it.elapsed(now) >= it.life {
|
||||
@@ -118,6 +122,15 @@ func (q *toastQueue) expire(now time.Time) (next time.Duration) {
|
||||
}
|
||||
q.items = live
|
||||
q.promote(now)
|
||||
}
|
||||
|
||||
// settle pauses the toasts held, resumes the rest, and returns the wait until one expires.
|
||||
func (q *toastQueue) settle(now time.Time, held map[string]bool) (next time.Duration) {
|
||||
for _, it := range q.items {
|
||||
if it.visible {
|
||||
q.pause(it.msg.ID, held[it.msg.ID], now)
|
||||
}
|
||||
}
|
||||
for _, it := range q.items {
|
||||
if it.visible && it.life > 0 && !it.runFrom.IsZero() {
|
||||
left := it.life - it.elapsed(now)
|
||||
@@ -172,15 +185,12 @@ func DismissToast(c *ui.Context, id string) {
|
||||
c.Invalidate()
|
||||
}
|
||||
|
||||
// Toaster draws the window's toasts at its bottom right. Call it once per
|
||||
// frame, last in the view. Toasts never take the focus; Tab reaches their
|
||||
// buttons. Hover or focus inside a toast pauses its time.
|
||||
// Toaster draws the window's toasts, never taking the focus; call it last in the view each frame.
|
||||
func Toaster(c *ui.Context) ToasterResult {
|
||||
q := toasts(c)
|
||||
now := c.Now()
|
||||
if next := q.expire(now); next > 0 {
|
||||
c.After(next)
|
||||
}
|
||||
q.expire(now)
|
||||
held := map[string]bool{}
|
||||
var res ToasterResult
|
||||
if len(q.items) == 0 {
|
||||
return res
|
||||
@@ -201,8 +211,8 @@ func Toaster(c *ui.Context) ToasterResult {
|
||||
q.items[i].announced = true
|
||||
c.Announce(it.msg.Message)
|
||||
}
|
||||
action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg)
|
||||
q.pause(it.msg.ID, held, now)
|
||||
action, closed, hold := toastCard(c, k, u, min(360, w-u*8), it.msg)
|
||||
held[it.msg.ID] = hold
|
||||
switch {
|
||||
case action:
|
||||
res.Action = it.msg.ID
|
||||
@@ -220,6 +230,12 @@ func Toaster(c *ui.Context) ToasterResult {
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
// Scheduled after resuming, so a toast released this frame still expires.
|
||||
q.wake = time.Time{}
|
||||
if next := q.settle(now, held); next > 0 {
|
||||
q.wake = now.Add(next)
|
||||
c.After(next)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
+34
-18
@@ -28,7 +28,8 @@ func TestToastQueueLimitsVisible(t *testing.T) {
|
||||
t.Fatalf("visible %v waiting %v", v, w)
|
||||
}
|
||||
// d starts its 5 s only when it shows.
|
||||
if next := q.expire(t0.Add(ToastDuration)); next != ToastDuration {
|
||||
q.expire(t0.Add(ToastDuration))
|
||||
if next := q.settle(t0.Add(ToastDuration), nil); next != ToastDuration {
|
||||
t.Fatalf("next wake %v", next)
|
||||
}
|
||||
if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) {
|
||||
@@ -66,15 +67,16 @@ func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
|
||||
t0 := time.Unix(0, 0)
|
||||
q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0)
|
||||
q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0)
|
||||
q.pause("w", true, t0.Add(4*time.Second))
|
||||
if next := q.expire(t0.Add(time.Hour)); next != 0 {
|
||||
q.settle(t0.Add(4*time.Second), map[string]bool{"w": true})
|
||||
q.expire(t0.Add(time.Hour))
|
||||
if next := q.settle(t0.Add(time.Hour), map[string]bool{"w": true}); next != 0 {
|
||||
t.Fatalf("paused toast still counts down: %v", next)
|
||||
}
|
||||
if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) {
|
||||
t.Fatalf("visible %v", toastIDs(&q, true))
|
||||
}
|
||||
q.pause("w", false, t0.Add(time.Hour))
|
||||
if next := q.expire(t0.Add(time.Hour)); next != time.Second {
|
||||
// Released: the wake-up is computed after resuming, in the same call.
|
||||
if next := q.settle(t0.Add(time.Hour), nil); next != time.Second {
|
||||
t.Fatalf("resumed toast left %v, want 1s", next)
|
||||
}
|
||||
q.expire(t0.Add(time.Hour + time.Second))
|
||||
@@ -84,7 +86,7 @@ func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToastQueuePanics(t *testing.T) {
|
||||
for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}} {
|
||||
for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}, {Message: "x", Severity: 9}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
@@ -156,27 +158,41 @@ func TestToastKeepsFocusAndEscapeCloses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToastExpiresAndHoverPauses(t *testing.T) {
|
||||
var res ToasterResult
|
||||
tt := toastTester(ToastMessage{Message: "Copied", Duration: 40 * time.Millisecond}, &res)
|
||||
var q *toastQueue
|
||||
const life = 40 * time.Millisecond
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Notify", ButtonOptions{}).Clicked() {
|
||||
ShowToast(c, ToastMessage{Message: "Copied", Duration: life})
|
||||
}
|
||||
q = toasts(c)
|
||||
Toaster(c)
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Notify")
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Copied") {
|
||||
t.Fatal("toast outlived its duration")
|
||||
if q.wake.IsZero() {
|
||||
t.Fatal("no wake-up scheduled for a running toast")
|
||||
}
|
||||
_ = tt.Click("Notify")
|
||||
r, _ := tt.Find("Copied")
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !q.wake.IsZero() {
|
||||
t.Fatal("hovered toast still schedules its expiry")
|
||||
}
|
||||
time.Sleep(2 * life)
|
||||
released := time.Now()
|
||||
tt.Move(5, 395)
|
||||
done := time.Now()
|
||||
// The frame that ends the hover must schedule the expiry itself.
|
||||
if q.wake.IsZero() || q.wake.Before(released) || q.wake.After(done.Add(life)) {
|
||||
t.Fatalf("release scheduled wake %v, released %v", q.wake, released)
|
||||
}
|
||||
if !tt.HasText("Copied") {
|
||||
t.Fatal("hovered toast expired")
|
||||
}
|
||||
tt.Move(5, 395)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
// Stands in for the frame MyGo's timer runs at q.wake.
|
||||
time.Sleep(time.Until(q.wake) + 5*time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Copied") {
|
||||
t.Fatal("toast stayed after the pointer left")
|
||||
t.Fatal("toast stayed after its wake-up")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user