diff --git a/docs/components/093-progress.md b/docs/components/093-progress.md index 36caa84..ebfc47f 100644 --- a/docs/components/093-progress.md +++ b/docs/components/093-progress.md @@ -42,7 +42,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{ | `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 | | `Label` | `string` | `""` | 名称。 | | `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 | -| `Width` | `float32` | 填满 | 宽度。 | +| `Width` | `float32` | 填满 | 宽度;负数、NaN 或无穷 panic。 | ## 状态 @@ -60,5 +60,5 @@ mujica.Meter(c, 93, mujica.MeterOptions{ ## 限制 -- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。 +- 百分比按缩放后的文字尺寸测量,放不进环内时显示在环旁边。 - MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。 diff --git a/feedback_review_test.go b/feedback_review_test.go index d332dad..39db79e 100644 --- a/feedback_review_test.go +++ b/feedback_review_test.go @@ -60,6 +60,9 @@ func TestFeedbackRejectsInvalidInput(t *testing.T) { "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}) }, + "meter negative width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: -1}) }, + "meter NaN width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(nan)}) }, + "meter infinite width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(inf)}) }, } { func() { defer func() { @@ -82,3 +85,75 @@ func TestNotificationTitleOnlyHitHeight(t *testing.T) { t.Fatalf("title-only notification hit box %v %v", r, ok) } } + +func TestNotificationHeaderWrapsAtDoubleText(t *testing.T) { + items := []Notification{{ID: "a", Title: "Build failed"}, {ID: "b", Title: "Backup complete", Read: true}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(520).Height(700).Children(func() { + NotificationCenter(c, &items, NotificationCenterOptions{}) + }) + }, 600, 760) + tt.SetPreferences(ui.Preferences{TextScale: 2}) + for _, s := range []string{"Notifications", "1 unread", "Mark all read", "Clear all"} { + if r, ok := tt.Find(s); !ok || r.W == 0 || r.H == 0 || r.X < 0 || r.X+r.W > 520 { + t.Errorf("%q at 200%%: %v %v", s, r, ok) + } + } + if err := tt.Click("Clear all"); err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("Clear all at 200%% left %d items", len(items)) + } +} + +func TestProgressRingCaptionFitsAtDoubleText(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + Progress(c, 1, ProgressOptions{Shape: ProgressRing, ShowValue: true}) + }) + }, 400, 200) + for _, scale := range []float32{1, 2} { + tt.SetPreferences(ui.Preferences{TextScale: scale}) + tt.Frame() + ring, ok := tt.Find("Loading") + text, ok2 := tt.Find("100%") + if !ok || !ok2 { + t.Fatalf("scale %v: ring %v text %v", scale, ok, ok2) + } + stroke := max(float32(3), ring.W/10) + inside := text.X >= ring.X+stroke && text.X+text.W <= ring.X+ring.W-stroke + outside := text.X >= ring.X+ring.W + if !inside && !outside { + t.Errorf("scale %v: caption %v clipped by ring %v", scale, text, ring) + } + if scale == 1 && !inside { + t.Errorf("caption left the ring at 100%%: %v", text) + } + } +} + +func TestProgressResumesFromDisplayedValueAfterReducedMotion(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) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + v = 0.5 + tt.Frame() + tt.SetPreferences(ui.Preferences{TextScale: 1}) + if got, want := progressPixel(tt, bar.X+bar.W*0.45, bar.Y+bar.H/2), theme.Light().AccentText; got != want { + t.Fatalf("after motion returned the fill at 45%% is %v, want %v", got, want) + } + v = 1 + tt.Frame() + if got := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2); got == theme.Light().AccentText { + t.Fatal("restored motion did not animate the next change") + } +} diff --git a/notification_center.go b/notification_center.go index f712d4d..cbcb116 100644 --- a/notification_center.go +++ b/notification_center.go @@ -67,24 +67,28 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border). 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(FontSize(c, theme.H3Size)).TextColor(k.Text) - if unread > 0 { - 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() { - for i := range *items { - (*items)[i].Read = true + // Wraps so title and actions stay whole when text is enlarged. + ui.Row(c).Wrap().AlignItems(ui.Center).Justify(ui.SpaceBetween).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).SingleLine() + if unread > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).SingleLine() } - res.MarkedAllRead = true - slog.Debug("mujica: notification_center mark all read", "count", unread) - } - if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() { - slog.Debug("mujica: notification_center clear all", "count", len(*items)) - *items = (*items)[:0] - res.ClearedAll = true - } + }) + ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() { + if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() { + for i := range *items { + (*items)[i].Read = true + } + res.MarkedAllRead = true + slog.Debug("mujica: notification_center mark all read", "count", unread) + } + if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() { + slog.Debug("mujica: notification_center clear all", "count", len(*items)) + *items = (*items)[:0] + res.ClearedAll = true + } + }) }) // Ornament hairline under the panel title. ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone) diff --git a/progress.go b/progress.go index 08f60cd..1bac8af 100644 --- a/progress.go +++ b/progress.go @@ -78,8 +78,8 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { } bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border) shown := float32(value) - if !opts.Indeterminate && !still { - shown = bar.Animate("progress.value", shown, theme.StateDuration) + if !opts.Indeterminate { + shown = progressAnimate(bar, shown, still) } bar.Draw(func(p *ui.Painter, r ui.Rect) { switch { @@ -110,10 +110,13 @@ 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 && !still { - shown = ring.Animate("progress.value", shown, theme.StateDuration) + if !opts.Indeterminate { + shown = progressAnimate(ring, shown, still) } stroke := max(3, d/10) + size := FontSize(c, theme.CaptionSize) + tw, th := c.MeasureText(0, ui.Span{Text: caption, Size: size}) + inside := caption != "" && !opts.Indeterminate && tw+4 <= d-2*stroke && th <= d-2*stroke ring.Draw(func(p *ui.Painter, r ui.Rect) { cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (r.W-stroke)/2) var track ui.Path @@ -132,19 +135,19 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c p.StrokePath(progressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText) } }) - if caption != "" && d >= 44 && !opts.Indeterminate { + if inside { ring.Children(func() { - ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text) + ui.Text(c, caption).FontSize(size).FontFeatures("tnum").TextColor(k.Text) }) ring.Center() } - if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) { + if opts.Label != "" || (caption != "" && !inside) { ui.Column(c).Children(func() { if opts.Label != "" { ui.Text(c, opts.Label).TextColor(k.Text) } - if caption != "" && (d < 44 || opts.Indeterminate) { - ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + if caption != "" && !inside { + ui.Text(c, caption).TextColor(k.TextMuted).FontSize(size).FontFeatures("tnum") } }) } @@ -152,6 +155,15 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c return row } +// progressAnimate eases toward target; under reduced motion it lands there and keeps the state in step. +func progressAnimate(e *ui.Element, target float32, still bool) float32 { + if still { + // A changed target lands at once under reduced motion; the detour stops a running transition. + e.Animate("progress.value", target+1, theme.StateDuration) + } + return e.Animate("progress.value", target, theme.StateDuration) +} + // progressArc is the arc from turn a to turn b, clockwise from twelve o'clock. func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path { var p ui.Path @@ -230,6 +242,9 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { } frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) } col := ui.Column(c).Gap(u * 1.5) + if !(opts.Width >= 0) || math.IsInf(float64(opts.Width), 1) { + panic(fmt.Sprintf("mujica: Meter Width %v invalid", opts.Width)) + } if opts.Width > 0 { col.Width(opts.Width) } else { diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png index cead596..14f8a0b 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 459659c..2e2b633 100644 Binary files a/testdata/snapshots/darwin/progress-light.png and b/testdata/snapshots/darwin/progress-light.png differ