diff --git a/alert.go b/alert.go index 2b5f116..8d78108 100644 --- a/alert.go +++ b/alert.go @@ -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") diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md index 8609972..36caa84 100644 --- a/docs/components/093-progress.md +++ b/docs/components/093-progress.md @@ -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”文字与阈值刻度,并把级别写入无障碍名称。 ## 事件 diff --git a/docs/components/094-spinner.md b/docs/components/094-spinner.md index 06d612f..f4698c3 100644 --- a/docs/components/094-spinner.md +++ b/docs/components/094-spinner.md @@ -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` | 标记颜色。 | diff --git a/docs/components/095-skeleton.md b/docs/components/095-skeleton.md index 4e7e7e1..188319f 100644 --- a/docs/components/095-skeleton.md +++ b/docs/components/095-skeleton.md @@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() { | `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 | | `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 | -负值 panic。 +负值、NaN、无穷或未知形状 panic。文字行高随系统文本缩放。 ## 状态 diff --git a/empty_state.go b/empty_state.go index 67ca961..2951801 100644 --- a/empty_state.go +++ b/empty_state.go @@ -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() { diff --git a/feedback_review_test.go b/feedback_review_test.go new file mode 100644 index 0000000..d332dad --- /dev/null +++ b/feedback_review_test.go @@ -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) + } +} diff --git a/loading_overlay.go b/loading_overlay.go index 6f8ba0d..5e826b4 100644 --- a/loading_overlay.go +++ b/loading_overlay.go @@ -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") diff --git a/notification_center.go b/notification_center.go index 19a4f93..f712d4d 100644 --- a/notification_center.go +++ b/notification_center.go @@ -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 != "" { diff --git a/progress.go b/progress.go index 85d09dc..08f60cd 100644 --- a/progress.go +++ b/progress.go @@ -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) } diff --git a/progress_test.go b/progress_test.go index 78c5bdf..4016a1e 100644 --- a/progress_test.go +++ b/progress_test.go @@ -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) { diff --git a/result.go b/result.go index 70814d0..537cc19 100644 --- a/result.go +++ b/result.go @@ -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) } diff --git a/skeleton.go b/skeleton.go index eefa1b5..3c4eb04 100644 --- a/skeleton.go +++ b/skeleton.go @@ -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 { diff --git a/spinner.go b/spinner.go index 2f1e9dd..280f968 100644 --- a/spinner.go +++ b/spinner.go @@ -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 diff --git a/status_indicator.go b/status_indicator.go index 045c642..1ebcf5a 100644 --- a/status_indicator.go +++ b/status_indicator.go @@ -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 } diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png index 28c32d3..cead596 100644 Binary files a/testdata/snapshots/darwin/progress-dark.png and b/testdata/snapshots/darwin/progress-dark.png differ diff --git a/testdata/snapshots/darwin/progress-light.png b/testdata/snapshots/darwin/progress-light.png index ffa4b25..459659c 100644 Binary files a/testdata/snapshots/darwin/progress-light.png and b/testdata/snapshots/darwin/progress-light.png differ diff --git a/toast.go b/toast.go index e15a6d4..872997a 100644 --- a/toast.go +++ b/toast.go @@ -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 } diff --git a/toast_test.go b/toast_test.go index d4425ba..c0da1f0 100644 --- a/toast_test.go +++ b/toast_test.go @@ -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") } }