Merge branch 'worktree-agent-ab9ba373bc47c2352'
This commit is contained in:
6 files changed
+122
-28
No files matched your search
@@ -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` 元素绘制。
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+21
-17
@@ -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)
|
||||
|
||||
+24
-9
@@ -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 {
|
||||
|
||||
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 |
Reference in new issue
Block a user