Merge branch 'worktree-agent-ab9ba373bc47c2352'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:43:04 -04:00
commit 396c814348
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") return k.Warning, icons.Must("triangle-alert")
case SeverityError: case SeverityError:
return k.Danger, icons.Must("circle-x") 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. // AlertOptions configure an Alert.
@@ -66,9 +68,7 @@ type AlertResult struct {
Closed bool Closed bool
} }
// Alert shows an inline message marked by color, icon and accessible name. // Alert shows an inline message; a non-nil open adds a dismiss button that clears it.
// A non-nil open adds a dismiss button that sets *open to false; nothing
// shows while *open is false.
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult { func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
if message == "" { if message == "" {
panic("mujica: Alert needs a 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(不确定模式忽略)。 | | `value` | `float64` | 必填 | 0 到 1;超出或 NaN 时 panic(不确定模式忽略)。 |
| `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`。 | | `Shape` | `ProgressShape` | `ProgressLinear` | `ProgressLinear` 或 `ProgressRing`;其他值 panic。 |
| `Indeterminate` | `bool` | `false` | 未知长度的进度。 | | `Indeterminate` | `bool` | `false` | 未知长度的进度。 |
| `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 | | `Label` | `string` | `""` | 显示在旁边并作为无障碍名称;为空时名称为“Loading”。 |
| `ShowValue` | `bool` | `false` | 显示百分比。 | | `ShowValue` | `bool` | `false` | 显示百分比。 |
| `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数 panic。 | | `Size` | `float32` | 环形 48、线形填满 | 环形直径或线形宽度;负数、NaN 或无穷 panic。 |
`MeterOptions`: `MeterOptions`:
| 字段 | 类型 | 默认值 | 含义 | | 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `value` | `float64` | 必填 | 须在范围内,否则 panic。 | | `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` 时低值为坏(如电量)。须在范围内。 | | `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
| `Label` | `string` | `""` | 名称。 | | `Label` | `string` | `""` | 名称。 |
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 | | `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
@@ -47,7 +47,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{
## 状态 ## 状态
- 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。 - 确定进度变化时以 120 ms 过渡,减少动画时直接跳到新值。
- 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示静态填充和“In progress”文字。 - 不确定进度只在绘制时移动(只重绘、不重建视图);减少动画时显示满格静态填充(对比度不低于 3:1)和“In progress”文字;切换到减少动画时进行中的过渡直接跳到目标值。
- 计量条按级别使用 Success / Warning / Danger 颜色,同时显示“Normal / High / Critical / Low”文字与阈值刻度,并把级别写入无障碍名称。 - 计量条按级别使用 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` 时也显示在旁边。 | | `Label` | `string` | 内置“Loading / 加载中” | 无障碍名称;`ShowLabel` 时也显示在旁边。 |
| `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 | | `ShowLabel` | `bool` | `false` | 在标记旁显示说明。 |
| `Color` | `ui.Color` | `AccentText` | 标记颜色。 | | `Color` | `ui.Color` | `AccentText` | 标记颜色。 |
+1 -1
View File
@@ -26,7 +26,7 @@ ui.Row(c).Gap(12).Children(func() {
| `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 | | `Height` | `float32` | 当前密度控件高度 | 矩形高度;文字形状由行数决定(每行 21 DIP)。 |
| `Lines` | `int` | 1 | 文字行数,多行时最后一行为 60% 宽。 | | `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 { if !opts.NoIcon {
ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0) 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) ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight)
if opts.Action != "" { if opts.Action != "" {
ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() { 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 Canceled bool
} }
// LoadingOverlay builds content and, while loading, covers it with a // LoadingOverlay covers content while loading, blocking input to that region only.
// spinner and label. Covered content takes no pointer, keys or focus;
// the rest of the window stays usable. Focus inside moves to the overlay.
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult { func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
if content == nil { if content == nil {
panic("mujica: LoadingOverlay needs content") panic("mujica: LoadingOverlay needs content")
+5 -7
View File
@@ -44,9 +44,7 @@ type NotificationCenterResult struct {
ClearedAll bool ClearedAll bool
} }
// NotificationCenter lists *items and edits them in place: read, unread, // NotificationCenter lists *items and edits them in place: read, unread, remove, clear.
// remove, clear. The list is one Tab stop; arrows move among its buttons
// and Delete removes the focused notification.
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult { func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
if items == nil { if items == nil {
panic("mujica: NotificationCenter needs items") panic("mujica: NotificationCenter needs items")
@@ -70,9 +68,9 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC
Role(ui.RoleGroup).Label(title).Clip() Role(ui.RoleGroup).Label(title).Clip()
panel.Children(func() { panel.Children(func() {
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).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 { 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) ui.Spacer(c)
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() { 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) 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) Radius(theme.ControlRadius).Label(name)
internal.Ring(open, k.Focus, theme.ControlRadius) internal.Ring(open, k.Focus, theme.ControlRadius)
open.Children(func() { open.Children(func() {
@@ -145,7 +143,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification,
t.Bold() t.Bold()
} }
if n.Time != "" { 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 != "" { if n.Body != "" {
+21 -19
View File
@@ -31,14 +31,16 @@ type ProgressOptions struct {
Size float32 Size float32
} }
// Progress shows value, from 0 to 1, of work done. It panics outside that range. // Progress shows work done, value 0 to 1; indeterminate ones hold still under reduced motion.
// Indeterminate indicators move only while painted and hold still under reduced motion.
func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element { 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) { if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) {
panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value)) panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value))
} }
if opts.Size < 0 { if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic("mujica: negative Progress Size") panic(fmt.Sprintf("mujica: Progress Size %v invalid", opts.Size))
} }
k := Tokens(c) k := Tokens(c)
u := Density(c).Unit() u := Density(c).Unit()
@@ -71,12 +73,12 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
if opts.Label != "" || caption != "" { if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() { ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) 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) bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border)
shown := float32(value) shown := float32(value)
if !opts.Indeterminate { if !opts.Indeterminate && !still {
shown = bar.Animate("progress.value", shown, theme.StateDuration) shown = bar.Animate("progress.value", shown, theme.StateDuration)
} }
bar.Draw(func(p *ui.Painter, r ui.Rect) { 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: case !opts.Indeterminate:
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius) p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius)
case still: case still:
p.Fill(r, k.AccentText.Alpha(0.4), theme.ControlRadius) p.Fill(r, k.AccentText, theme.ControlRadius)
default: default:
p.AnimationFrame() p.AnimationFrame()
phase := float32(p.Now().UnixMilli()%1400) / 1400 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() { row.Children(func() {
ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent) ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent)
shown := float32(acc) shown := float32(acc)
if !opts.Indeterminate { if !opts.Indeterminate && !still {
shown = ring.Animate("progress.value", shown, theme.StateDuration) shown = ring.Animate("progress.value", shown, theme.StateDuration)
} }
stroke := max(3, d/10) 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) p.StrokePath(progressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText)
} }
case still: case still:
p.StrokePath(&track, stroke, k.AccentText.Alpha(0.4)) p.StrokePath(&track, stroke, k.AccentText)
default: default:
p.AnimationFrame() p.AnimationFrame()
start := float32(p.Now().UnixMilli()%1200) / 1200 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 { if caption != "" && d >= 44 && !opts.Indeterminate {
ring.Children(func() { 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() 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) ui.Text(c, opts.Label).TextColor(k.Text)
} }
if caption != "" && (d < 44 || opts.Indeterminate) { 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 { type MeterOptions struct {
// Min and Max bound the value; both zero mean 0 to 1. // Min and Max bound the value; both zero mean 0 to 1.
Min, Max float64 Min, Max float64
// Levels mark the value High from Warning and Critical from Critical; // Levels are the High and Critical thresholds; Critical below Warning flags low values.
// Critical below Warning flags low values instead. Nil marks none.
Levels *ui.MeterLevels Levels *ui.MeterLevels
Label string Label string
ShowValue bool ShowValue bool
@@ -195,21 +196,20 @@ func meterLevel(v float64, l *ui.MeterLevels) int {
return 0 return 0
} }
// Meter shows a level within a range, as disk use. Thresholds show as // Meter shows a level within a range with threshold ticks and a level word, not color alone.
// ticks and the level as a word as well as a color. It panics on a value outside the range.
func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element { func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
lo, hi := opts.Min, opts.Max lo, hi := opts.Min, opts.Max
if lo == 0 && hi == 0 { if lo == 0 && hi == 0 {
hi = 1 hi = 1
} }
if !(hi > lo) { if !finite(lo) || !finite(hi) || !(hi > lo) {
panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi)) panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi))
} }
if math.IsNaN(value) || value < lo || value > hi { if math.IsNaN(value) || value < lo || value > hi {
panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, 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) { 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") panic(fmt.Sprintf("mujica: Meter levels %+v outside %v..%v", *l, lo, hi))
} }
k := Tokens(c) k := Tokens(c)
u := Density(c).Unit() u := Density(c).Unit()
@@ -246,7 +246,7 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
if opts.Label != "" || caption != "" { if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() { ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1) 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 name := opts.Label
@@ -270,3 +270,5 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
}) })
return col return col
} }
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
+46
View File
@@ -5,8 +5,54 @@ import (
"testing" "testing"
"github.com/egoist/mygo/ui" "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) { func TestProgressValueText(t *testing.T) {
v := 0.424 v := 0.424
tt := ui.NewTester(func(c *ui.Context) { tt := ui.NewTester(func(c *ui.Context) {
+2 -3
View File
@@ -38,8 +38,7 @@ type ResultResult struct {
Action int Action int
} }
// Result reports the outcome of a finished task and offers next steps. // Result shows a task outcome and reports the chosen next step; the caller navigates.
// It only reports the chosen step; the caller navigates.
func Result(c *ui.Context, opts ResultOptions) ResultResult { func Result(c *ui.Context, opts ResultOptions) ResultResult {
k := Tokens(c) k := Tokens(c)
u := Density(c).Unit() 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 = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*3).Padding(u*10, u*6).Role(ui.RoleGroup).Label(title)
res.Element.Children(func() { res.Element.Children(func() {
ui.Icon(c, icon).Size(48, 48).TextColor(tone) 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 != "" { if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(420).LineHeight(theme.LineHeight) 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 package mujica
import ( import (
"fmt"
"math" "math"
"github.com/egoist/mygo/ui" "github.com/egoist/mygo/ui"
@@ -28,11 +29,13 @@ type SkeletonOptions struct {
Lines int Lines int
} }
// Skeleton stands in for content that is loading. It pulses while painted // Skeleton stands in for loading content; it pulses while painted, static under reduced motion.
// and shows a static fill under reduced motion.
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element { func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 { if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect {
panic("mujica: negative Skeleton size") 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) k := Tokens(c)
still := c.Preferences().ReduceMotion 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) }) e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) })
default: default:
lines := max(opts.Lines, 1) 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) { 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 { for i := range lines {
w := r.W w := r.W
if lines > 1 && i == lines-1 { if lines > 1 && i == lines-1 {
+6 -3
View File
@@ -1,6 +1,7 @@
package mujica package mujica
import ( import (
"fmt"
"math" "math"
"github.com/egoist/mygo/ui" "github.com/egoist/mygo/ui"
@@ -18,12 +19,14 @@ type SpinnerOptions struct {
Color ui.Color Color ui.Color
} }
// Spinner draws four diamonds turning around a center while it shows. // Spinner turns four diamonds only while painted; hidden or under reduced motion it stops.
// It repaints only while painted: hidden, it stops. Reduced motion holds it still.
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { 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) k := Tokens(c)
size := opts.Size size := opts.Size
if size <= 0 { if size == 0 {
size = 16 size = 16
} }
label := opts.Label 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. // 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 { func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions) *ui.Element {
key := "status_indicator." + status.String() // panics on an unknown Status
k := Tokens(c) k := Tokens(c)
u := Density(c).Unit() u := Density(c).Unit()
label := opts.Label label := opts.Label
if label == "" { if label == "" {
label = msg(c, "status_indicator."+status.String()) label = msg(c, key)
} }
var tone ui.Color var tone ui.Color
var icon *ui.SVG var icon *ui.SVG
@@ -71,7 +72,7 @@ func StatusIndicator(c *ui.Context, status Status, opts StatusIndicatorOptions)
} else { } else {
ui.Icon(c, icon).Size(16, 16).TextColor(tone) 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 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. // 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{} type toastKey struct{}
@@ -64,6 +67,7 @@ func (q *toastQueue) push(m ToastMessage, now time.Time) {
if m.Message == "" { if m.Message == "" {
panic("mujica: toast needs a Message") panic("mujica: toast needs a Message")
} }
_ = m.Severity.String() // panics on an unknown Severity
if m.ID == "" { if m.ID == "" {
m.ID = m.Message m.ID = m.Message
} }
@@ -106,8 +110,8 @@ func (it *toastItem) elapsed(now time.Time) time.Duration {
return it.spent + now.Sub(it.runFrom) return it.spent + now.Sub(it.runFrom)
} }
// expire drops toasts whose time ran out and returns the wait until the next does. // expire drops toasts whose time ran out and shows queued ones.
func (q *toastQueue) expire(now time.Time) (next time.Duration) { func (q *toastQueue) expire(now time.Time) {
live := q.items[:0] live := q.items[:0]
for _, it := range q.items { for _, it := range q.items {
if it.visible && it.life > 0 && it.elapsed(now) >= it.life { 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.items = live
q.promote(now) 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 { for _, it := range q.items {
if it.visible && it.life > 0 && !it.runFrom.IsZero() { if it.visible && it.life > 0 && !it.runFrom.IsZero() {
left := it.life - it.elapsed(now) left := it.life - it.elapsed(now)
@@ -172,15 +185,12 @@ func DismissToast(c *ui.Context, id string) {
c.Invalidate() c.Invalidate()
} }
// Toaster draws the window's toasts at its bottom right. Call it once per // Toaster draws the window's toasts, never taking the focus; call it last in the view each frame.
// frame, last in the view. Toasts never take the focus; Tab reaches their
// buttons. Hover or focus inside a toast pauses its time.
func Toaster(c *ui.Context) ToasterResult { func Toaster(c *ui.Context) ToasterResult {
q := toasts(c) q := toasts(c)
now := c.Now() now := c.Now()
if next := q.expire(now); next > 0 { q.expire(now)
c.After(next) held := map[string]bool{}
}
var res ToasterResult var res ToasterResult
if len(q.items) == 0 { if len(q.items) == 0 {
return res return res
@@ -201,8 +211,8 @@ func Toaster(c *ui.Context) ToasterResult {
q.items[i].announced = true q.items[i].announced = true
c.Announce(it.msg.Message) c.Announce(it.msg.Message)
} }
action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg) action, closed, hold := toastCard(c, k, u, min(360, w-u*8), it.msg)
q.pause(it.msg.ID, held, now) held[it.msg.ID] = hold
switch { switch {
case action: case action:
res.Action = it.msg.ID res.Action = it.msg.ID
@@ -220,6 +230,12 @@ func Toaster(c *ui.Context) ToasterResult {
c.Invalidate() 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 return res
} }
+34 -18
View File
@@ -28,7 +28,8 @@ func TestToastQueueLimitsVisible(t *testing.T) {
t.Fatalf("visible %v waiting %v", v, w) t.Fatalf("visible %v waiting %v", v, w)
} }
// d starts its 5 s only when it shows. // 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) t.Fatalf("next wake %v", next)
} }
if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) { 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) t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0) q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0)
q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0) q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0)
q.pause("w", true, t0.Add(4*time.Second)) q.settle(t0.Add(4*time.Second), map[string]bool{"w": true})
if next := q.expire(t0.Add(time.Hour)); next != 0 { 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) t.Fatalf("paused toast still counts down: %v", next)
} }
if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) { if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) {
t.Fatalf("visible %v", toastIDs(&q, true)) t.Fatalf("visible %v", toastIDs(&q, true))
} }
q.pause("w", false, t0.Add(time.Hour)) // Released: the wake-up is computed after resuming, in the same call.
if next := q.expire(t0.Add(time.Hour)); next != time.Second { if next := q.settle(t0.Add(time.Hour), nil); next != time.Second {
t.Fatalf("resumed toast left %v, want 1s", next) t.Fatalf("resumed toast left %v, want 1s", next)
} }
q.expire(t0.Add(time.Hour + time.Second)) q.expire(t0.Add(time.Hour + time.Second))
@@ -84,7 +86,7 @@ func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
} }
func TestToastQueuePanics(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() { func() {
defer func() { defer func() {
if recover() == nil { if recover() == nil {
@@ -156,27 +158,41 @@ func TestToastKeepsFocusAndEscapeCloses(t *testing.T) {
} }
func TestToastExpiresAndHoverPauses(t *testing.T) { func TestToastExpiresAndHoverPauses(t *testing.T) {
var res ToasterResult var q *toastQueue
tt := toastTester(ToastMessage{Message: "Copied", Duration: 40 * time.Millisecond}, &res) 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") _ = tt.Click("Notify")
time.Sleep(60 * time.Millisecond) if q.wake.IsZero() {
tt.Frame() t.Fatal("no wake-up scheduled for a running toast")
if tt.HasText("Copied") {
t.Fatal("toast outlived its duration")
} }
_ = tt.Click("Notify")
r, _ := tt.Find("Copied") r, _ := tt.Find("Copied")
tt.Move(r.X+r.W/2, r.Y+r.H/2) tt.Move(r.X+r.W/2, r.Y+r.H/2)
time.Sleep(60 * time.Millisecond) if !q.wake.IsZero() {
tt.Frame() 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") { if !tt.HasText("Copied") {
t.Fatal("hovered toast expired") t.Fatal("hovered toast expired")
} }
tt.Move(5, 395) // Stands in for the frame MyGo's timer runs at q.wake.
time.Sleep(60 * time.Millisecond) time.Sleep(time.Until(q.wake) + 5*time.Millisecond)
tt.Frame() tt.Frame()
if tt.HasText("Copied") { if tt.HasText("Copied") {
t.Fatal("toast stayed after the pointer left") t.Fatal("toast stayed after its wake-up")
} }
} }