Merge branch 'worktree-agent-ab9ba373bc47c2352'
This commit is contained in:
18 files changed
+250
-83
No files matched your search
@@ -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")
|
||||||
|
|||||||
@@ -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”文字与阈值刻度,并把级别写入无障碍名称。
|
||||||
|
|
||||||
## 事件
|
## 事件
|
||||||
|
|||||||
@@ -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` | 标记颜色。 |
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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) }
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
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 |
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user