Feedback: review r1 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:42:50 -04:00
1 parent bb50fa052f
commit 20aeb143ef
18 files changed
+250 -83

No files matched your search

+4 -4
View File
@@ -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")
+4 -4
View File
@@ -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”文字与阈值刻度,并把级别写入无障碍名称。
## 事件
+1 -1
View File
@@ -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` | 标记颜色。 |
+1 -1
View File
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 |
负值 panic。
负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。
## 状态
+1 -1
View File
@@ -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() {
+84
View File
@@ -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
View File
@@ -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")
+5 -7
View File
@@ -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
View File
@@ -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) }
+46
View File
@@ -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) {
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+27 -11
View File
@@ -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
View File
@@ -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")
}
}