WIP feedback

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:11:45 -04:00
1 parent 119f9047e9
commit a4b2dbaf13
25 files changed
+2141 -2

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Severity is the meaning of a message. The zero value is SeverityInfo.
type Severity int
const (
SeverityInfo Severity = iota
SeveritySuccess
SeverityWarning
SeverityError
)
func (s Severity) String() string {
switch s {
case SeverityInfo:
return "info"
case SeveritySuccess:
return "success"
case SeverityWarning:
return "warning"
case SeverityError:
return "error"
}
panic(fmt.Sprintf("mujica: unknown Severity %d", int(s)))
}
// severityLook returns the color and icon that, with text, mark s.
func severityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
switch s {
case SeveritySuccess:
return k.Success, icons.Must("circle-check")
case SeverityWarning:
return k.Warning, icons.Must("triangle-alert")
case SeverityError:
return k.Danger, icons.Must("circle-x")
}
return k.Info, icons.Must("info")
}
// AlertOptions configure an Alert.
type AlertOptions struct {
Severity Severity
// Title goes above the message in bold; empty shows none.
Title string
// Action labels a button at the end; empty shows none.
Action string
}
// AlertResult is what an Alert built and reported this frame.
type AlertResult struct {
// Element is nil while the alert is closed.
Element *ui.Element
// Action reports a click on the action button.
Action bool
// Closed reports the user dismissing the alert.
Closed bool
}
// Alert shows an inline message marked by color, icon and accessible name.
// A non-nil open adds a dismiss button that sets *open to false; nothing
// shows while *open is false.
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) AlertResult {
if message == "" {
panic("mujica: Alert needs a message")
}
if open != nil && !*open {
return AlertResult{}
}
k := Tokens(c)
u := Density(c).Unit()
tone, icon := severityLook(k, opts.Severity)
name := msg(c, "alert."+opts.Severity.String())
var res AlertResult
box := ui.Row(c).FillWidth().AlignItems(ui.Start).Gap(u*3).Padding(u*3, u*3, u*3, u*4).
Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Role(ui.RoleGroup).Label(name).Clip()
// A bar in the severity color along the leading edge.
box.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0)
})
// Warnings and errors are read out when they appear or change.
said := ui.Local(box, "alert.said", func() string { return "" })
if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news {
*said = news
c.Announce(news)
}
box.Children(func() {
ui.Icon(c, icon).Size(20, 20).TextColor(tone).Margin(1, 0, 0, 0)
ui.Column(c).Grow(1).Shrink(1).Gap(u).Children(func() {
if opts.Title != "" {
ui.Text(c, opts.Title).Bold().TextColor(k.Text)
}
ui.Text(c, message).TextColor(k.Text).LineHeight(theme.LineHeight)
if opts.Action != "" {
ui.Row(c).Margin(u, 0, 0, 0).Children(func() {
if Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() {
slog.Debug("mujica: alert action", "severity", opts.Severity, "action", opts.Action)
res.Action = true
}
})
}
})
if open != nil {
dismiss := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "alert.close")})
dismiss.TextColor(k.TextMuted).Padding(0).MinHeight(theme.MinHitSize).Margin(-u, -u, -u, 0)
if dismiss.Clicked() {
slog.Debug("mujica: alert closed", "severity", opts.Severity)
*open, res.Closed = false, true
}
}
})
res.Element = box
return res
}
+113
View File
@@ -0,0 +1,113 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAlertActionAndClose(t *testing.T) {
open := true
actions, closes := 0, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := Alert(c, &open, "Disk almost full", AlertOptions{Severity: SeverityWarning, Title: "Storage", Action: "Free space"})
if r.Action {
actions++
}
if r.Closed {
closes++
}
}, 500, 300)
if err := tt.Click("Free space"); err != nil {
t.Fatal(err)
}
if actions != 1 || !open {
t.Fatalf("actions %d open %v", actions, open)
}
if err := tt.Click("Dismiss"); err != nil {
t.Fatal(err)
}
if open || closes != 1 {
t.Fatalf("after dismiss open %v closes %d", open, closes)
}
if tt.HasText("Disk almost full") {
t.Fatal("closed alert still shows")
}
}
func TestAlertKeyboard(t *testing.T) {
open := true
actions := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Alert(c, &open, "Sync failed", AlertOptions{Severity: SeverityError, Action: "Retry"}).Action {
actions++
}
}, 500, 300)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if actions != 1 {
t.Fatalf("Enter on action: %d", actions)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if open {
t.Fatal("Space on dismiss left it open")
}
}
func TestAlertWithoutOpenHasNoDismiss(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Alert(c, nil, "Read only", AlertOptions{})
}, 500, 300)
if _, ok := tt.Find("Dismiss"); ok {
t.Fatal("dismiss button without open")
}
if _, ok := tt.Find("Information"); !ok {
t.Fatal("alert lacks its severity name")
}
}
func TestAlertAnnouncesWarningsOnce(t *testing.T) {
sev := SeverityInfo
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
Alert(c, nil, "磁盘已满", AlertOptions{Severity: sev})
}, 500, 300)
if a := tt.Announcements(); len(a) != 0 {
t.Fatalf("info announced %q", a)
}
sev = SeverityError
tt.Frame()
tt.Frame()
if a := tt.Announcements(); !slices.Equal(a, []string{"错误: 磁盘已满"}) {
t.Fatalf("error announcements %q", a)
}
}
func TestAlertPanicsWithoutMessage(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Alert(c, nil, "", AlertOptions{})
}, 300, 200)
}
func TestAlertSnapshot(t *testing.T) {
open := true
snapshot(t, "alert", 560, 400, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(12).Children(func() {
Alert(c, nil, "A new version is ready to install.", AlertOptions{})
Alert(c, &open, "Your changes were saved.", AlertOptions{Severity: SeveritySuccess})
Alert(c, nil, "The certificate expires in 3 days.", AlertOptions{Severity: SeverityWarning, Title: "Certificate"})
Alert(c, &open, "The archive could not be read.", AlertOptions{Severity: SeverityError, Title: "Import failed", Action: "Retry"})
})
})
}
+88
View File
@@ -0,0 +1,88 @@
package mujica
import (
"cmp"
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// EmptyKind tells why there is nothing to show. The zero value is EmptyNoData.
type EmptyKind int
const (
// EmptyNoData: the collection itself is empty.
EmptyNoData EmptyKind = iota
// EmptyNoResults: a search or filter matched nothing.
EmptyNoResults
)
// EmptyStateOptions configure an EmptyState. Empty texts use the kind's built-in copy.
type EmptyStateOptions struct {
Kind EmptyKind
Title string
Description string
// Query, for EmptyNoResults, is named in the default description.
Query string
// Icon replaces the kind's icon (inbox or search); NoIcon shows none.
Icon *ui.SVG
NoIcon bool
// Action labels a button, such as "Clear search"; empty shows none.
Action string
}
// EmptyStateResult is what an EmptyState built and reported this frame.
type EmptyStateResult struct {
Element *ui.Element
Action bool
}
// EmptyState explains an empty view and offers a next step.
func EmptyState(c *ui.Context, opts EmptyStateOptions) EmptyStateResult {
if opts.NoIcon && opts.Icon != nil {
panic("mujica: EmptyState with both Icon and NoIcon")
}
k := Tokens(c)
u := Density(c).Unit()
title, desc, icon := opts.Title, opts.Description, opts.Icon
switch opts.Kind {
case EmptyNoData:
if opts.Query != "" {
panic("mujica: EmptyState Query needs EmptyNoResults")
}
title = cmp.Or(title, msg(c, "empty_state.no_data.title"))
desc = cmp.Or(desc, msg(c, "empty_state.no_data.description"))
if icon == nil {
icon = icons.Must("inbox")
}
case EmptyNoResults:
title = cmp.Or(title, msg(c, "empty_state.no_results.title"))
if desc == "" && opts.Query != "" {
desc = fmt.Sprintf(msg(c, "empty_state.no_results.query"), opts.Query)
}
desc = cmp.Or(desc, msg(c, "empty_state.no_results.description"))
if icon == nil {
icon = icons.Must("search")
}
default:
panic(fmt.Sprintf("mujica: unknown EmptyKind %d", int(opts.Kind)))
}
var res EmptyStateResult
res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*2).Padding(u*8, u*4).Role(ui.RoleGroup).Label(title)
res.Element.Children(func() {
if !opts.NoIcon {
ui.Icon(c, icon).Size(32, 32).TextColor(k.TextMuted).Margin(0, 0, u*2, 0)
}
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text).TextAlign(ui.Center)
ui.Text(c, desc).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(360).LineHeight(theme.LineHeight)
if opts.Action != "" {
ui.Box(c).Margin(u*2, 0, 0, 0).Children(func() {
res.Action = Button(c, opts.Action, ButtonOptions{Variant: Secondary}).Clicked()
})
}
})
return res
}
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
func TestEmptyStateKinds(t *testing.T) {
opts := EmptyStateOptions{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
EmptyState(c, opts)
}, 500, 400)
if !tt.HasText("Nothing here yet") || !tt.HasText("Items you add will appear here.") {
t.Fatalf("no data: %q", tt.Texts())
}
opts = EmptyStateOptions{Kind: EmptyNoResults, Query: "invoice"}
tt.Frame()
if !tt.HasText("No results") || !tt.HasText("Nothing matches “invoice”.") {
t.Fatalf("no results: %q", tt.Texts())
}
opts = EmptyStateOptions{Kind: EmptyNoResults}
tt.Frame()
if !tt.HasText("Try other words or clear the filters.") {
t.Fatalf("no results without query: %q", tt.Texts())
}
}
func TestEmptyStateActionKeyboard(t *testing.T) {
n := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
if EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "发票", Action: "清除搜索"}).Action {
n++
}
}, 500, 400)
if !tt.HasText("没有与“发票”匹配的内容。") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if err := tt.Click("清除搜索"); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("actions %d", n)
}
}
func TestEmptyStatePanics(t *testing.T) {
for name, o := range map[string]EmptyStateOptions{
"icon and no icon": {Icon: icons.Must("inbox"), NoIcon: true},
"query without kind": {Query: "x"},
"unknown kind": {Kind: 9},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); EmptyState(c, o) }, 300, 200)
}()
}
}
func TestEmptyStateSnapshot(t *testing.T) {
snapshot(t, "empty_state", 640, 280, func(c *ui.Context) {
ui.Row(c).Padding(12).Gap(12).Children(func() {
ui.Column(c).Grow(1).Basis(0).Children(func() {
EmptyState(c, EmptyStateOptions{Action: "New project"})
})
ui.Column(c).Grow(1).Basis(0).Children(func() {
EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: "quarterly", Action: "Clear search"})
})
})
})
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"cmp"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// LoadingOverlayOptions configure a LoadingOverlay.
type LoadingOverlayOptions struct {
// Label explains the wait; empty uses "Loading…".
Label string
// Cancel labels a button that reports Canceled; empty shows none.
Cancel string
}
// LoadingOverlayResult is what a LoadingOverlay built and reported this frame.
type LoadingOverlayResult struct {
Element *ui.Element
// Canceled reports the cancel button or Escape while loading.
Canceled bool
}
// LoadingOverlay builds content and, while loading, covers it with a
// spinner and label. Covered content takes no pointer, keys or focus;
// the rest of the window stays usable. Focus inside moves to the overlay.
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
if content == nil {
panic("mujica: LoadingOverlay needs content")
}
k := Tokens(c)
u := Density(c).Unit()
label := cmp.Or(opts.Label, msg(c, "loading_overlay.label"))
var res LoadingOverlayResult
box := ui.Box(c)
was := ui.Local(box, "loading_overlay.loading", func() bool { return false })
if loading != *was {
slog.Debug("mujica: loading_overlay changed", "loading", loading)
if loading {
c.Announce(label)
}
*was = loading
}
box.Children(func() {
inner := ui.Column(c).FillWidth().Disabled(loading)
inner.Children(content)
if !loading {
return
}
held := inner.FocusWithin()
scrim := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Padding(u * 4).
Background(k.Background.Alpha(0.5)).Radius(theme.CardRadius)
scrim.Transition(ui.ElementTransition{Duration: theme.StateDuration, Enter: &ui.Motion{}, Exit: &ui.Motion{}})
scrim.Children(func() {
panel := ui.Column(c).AlignItems(ui.Center).Gap(u*3).Padding(u*4, u*6).Radius(theme.PopupRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleStatus).Label(label)
if opts.Cancel == "" {
// Without a button the panel keeps the focus in the region.
internal.Ring(panel.Focusable(), k.Focus, theme.PopupRadius)
if held {
panel.Focus()
}
}
panel.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
Spinner(c, SpinnerOptions{Size: 20, Label: label})
ui.Text(c, label).TextColor(k.Text)
})
if opts.Cancel == "" {
return
}
b := Button(c, opts.Cancel, ButtonOptions{Variant: Secondary})
if held {
b.Focus()
}
if b.Clicked() {
res.Canceled = true
}
})
if opts.Cancel != "" && panel.Shortcut(0, ui.KeyEscape) {
res.Canceled = true
}
})
})
if res.Canceled {
slog.Debug("mujica: loading_overlay canceled")
}
res.Element = box
return res
}
+44
View File
@@ -3,5 +3,49 @@ package mujica
func init() {
addMessages(map[string][2]string{
"loading": {"Loading", "加载中"},
"alert.close": {"Dismiss", "关闭"},
"alert.info": {"Information", "信息"},
"alert.success": {"Success", "成功"},
"alert.warning": {"Warning", "警告"},
"alert.error": {"Error", "错误"},
"toast.close": {"Dismiss notification", "关闭通知"},
"toast.region": {"Notifications", "通知"},
"progress.indeterminate": {"In progress", "进行中"},
"meter.normal": {"Normal", "正常"},
"meter.warning": {"High", "偏高"},
"meter.critical": {"Critical", "严重"},
"meter.low_warning": {"Low", "偏低"},
"empty_state.no_data.title": {"Nothing here yet", "暂无内容"},
"empty_state.no_data.description": {"Items you add will appear here.", "添加的内容会显示在这里。"},
"empty_state.no_results.title": {"No results", "没有结果"},
"empty_state.no_results.description": {"Try other words or clear the filters.", "请换个关键词,或清除筛选条件。"},
"empty_state.no_results.query": {"Nothing matches “%s”.", "没有与“%s”匹配的内容。"},
"result.success.title": {"Done", "操作成功"},
"result.failure.title": {"Something went wrong", "操作失败"},
"result.forbidden.title": {"No access", "无访问权限"},
"status_indicator.success": {"Healthy", "正常"},
"status_indicator.warning": {"Degraded", "警告"},
"status_indicator.error": {"Failing", "错误"},
"status_indicator.offline": {"Offline", "离线"},
"status_indicator.busy": {"Working", "处理中"},
"notification_center.title": {"Notifications", "通知"},
"notification_center.unread": {"%d unread", "%d 条未读"},
"notification_center.unread_mark": {"Unread", "未读"},
"notification_center.mark_read": {"Mark read", "标为已读"},
"notification_center.mark_unread": {"Mark unread", "标为未读"},
"notification_center.mark_all_read": {"Mark all read", "全部已读"},
"notification_center.clear": {"Clear", "清除"},
"notification_center.clear_all": {"Clear all", "全部清除"},
"notification_center.empty": {"You're all caught up", "没有新通知"},
"notification_center.empty_hint": {"New notifications will appear here.", "新的通知会显示在这里。"},
"loading_overlay.label": {"Loading…", "正在加载…"},
})
}
+189
View File
@@ -0,0 +1,189 @@
package mujica
import (
"cmp"
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Notification is one in-app message. The caller owns and persists the list.
type Notification struct {
// ID is required and unique within the list.
ID string
Title string
Body string
// Time shows as given, as "5 min ago".
Time string
Read bool
// Action labels a button; empty shows none.
Action string
}
// NotificationCenterOptions configure a NotificationCenter.
type NotificationCenterOptions struct {
// Title uses the built-in "Notifications" when empty.
Title string
}
// NotificationCenterResult reports the frame's events by notification ID.
type NotificationCenterResult struct {
Element *ui.Element
Action string
// Toggled changed its Read state; Opened was clicked and is now read.
Toggled string
Opened string
Removed string
// MarkedAllRead and ClearedAll report the header buttons.
MarkedAllRead bool
ClearedAll bool
}
// NotificationCenter lists *items and edits them in place: read, unread,
// remove, clear. The list is one Tab stop; arrows move among its buttons
// and Delete removes the focused notification.
func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationCenterOptions) NotificationCenterResult {
if items == nil {
panic("mujica: NotificationCenter needs items")
}
seen := map[string]bool{}
unread := 0
for _, n := range *items {
if n.ID == "" || seen[n.ID] {
panic(fmt.Sprintf("mujica: notification ID %q empty or repeated", n.ID))
}
seen[n.ID] = true
if !n.Read {
unread++
}
}
k := Tokens(c)
u := Density(c).Unit()
title := cmp.Or(opts.Title, msg(c, "notification_center.title"))
var res NotificationCenterResult
panel := ui.Column(c).FillWidth().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(theme.H3Size).TextColor(k.Text)
if unread > 0 {
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(theme.CaptionSize).TextColor(k.AccentText)
}
ui.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
}
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)
if len(*items) == 0 && !res.ClearedAll {
EmptyState(c, EmptyStateOptions{
Title: msg(c, "notification_center.empty"),
Description: msg(c, "notification_center.empty_hint"),
Icon: icons.Must("bell"),
})
return
}
list := ui.Scroll(c).Grow(1).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal)
list.Dividers(theme.BorderWidth, k.Border)
list.Children(func() {
for i := range *items {
notificationRow(c, k, u, &(*items)[i], &res)
}
})
})
if res.Removed != "" {
for i, n := range *items {
if n.ID == res.Removed {
*items = append((*items)[:i], (*items)[i+1:]...)
break
}
}
}
res.Element = panel
return res
}
func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, res *NotificationCenterResult) {
name := n.Title
if !n.Read {
name = msg(c, "notification_center.unread_mark") + ": " + n.Title
}
row := ui.Row(c).Key(n.ID).FillWidth().AlignItems(ui.Start).Gap(u*2).Padding(u*3, u*3, u*3, u*2).
Role(ui.RoleListItem).Label(name)
if row.Hovered() {
row.Background(k.SurfaceHover)
}
row.Children(func() {
// Unread mark: a diamond and bold title, not color alone.
ui.Box(c).Size(12, 20).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if !n.Read {
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).
Radius(theme.ControlRadius).Label(name)
internal.Ring(open, k.Focus, theme.ControlRadius)
open.Children(func() {
ui.Row(c).FillWidth().Gap(u * 2).Children(func() {
t := ui.Text(c, n.Title).TextColor(k.Text).Grow(1).Shrink(1)
if !n.Read {
t.Bold()
}
if n.Time != "" {
ui.Text(c, n.Time).FontSize(theme.CaptionSize).TextColor(k.TextMuted).SingleLine()
}
})
if n.Body != "" {
ui.Text(c, n.Body).TextColor(k.TextMuted).LineHeight(theme.LineHeight)
}
})
if open.Clicked() && !n.Read {
n.Read, res.Opened = true, n.ID
slog.Debug("mujica: notification_center opened", "id", n.ID)
}
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
if n.Action != "" && Button(c, n.Action, ButtonOptions{Variant: Outline}).Clicked() {
res.Action = n.ID
slog.Debug("mujica: notification_center action", "id", n.ID)
}
toggle, icon := "notification_center.mark_read", icons.Must("check")
if n.Read {
toggle, icon = "notification_center.mark_unread", icons.Must("circle")
}
if notificationIconButton(c, k, u, icon, msg(c, toggle)).Clicked() {
n.Read, res.Toggled = !n.Read, n.ID
slog.Debug("mujica: notification_center toggled", "id", n.ID, "read", n.Read)
}
if notificationIconButton(c, k, u, icons.Must("x"), msg(c, "notification_center.clear")).Clicked() {
res.Removed = n.ID
}
})
})
if row.Shortcut(0, ui.KeyDelete) || row.Shortcut(0, ui.KeyBackspace) {
res.Removed = n.ID
}
if res.Removed == n.ID {
slog.Debug("mujica: notification_center removed", "id", n.ID)
}
}
func notificationIconButton(c *ui.Context, k theme.Tokens, u float32, icon *ui.SVG, label string) *ui.Element {
b := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icon, Label: label})
b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label)
return b
}
+271
View File
@@ -0,0 +1,271 @@
package mujica
import (
"fmt"
"math"
"strconv"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// ProgressShape is how a progress indicator draws. The zero value is ProgressLinear.
type ProgressShape int
const (
ProgressLinear ProgressShape = iota
ProgressRing
)
// ProgressOptions configure a Progress.
type ProgressOptions struct {
Shape ProgressShape
// Indeterminate shows work of unknown length; the value is ignored.
Indeterminate bool
// Label names the indicator and shows beside it; empty reads "Loading".
Label string
ShowValue bool
// Size is the ring diameter (48 DIP when zero) or the bar width (fills when zero).
Size float32
}
// Progress shows value, from 0 to 1, of work done. It panics outside that range.
// Indeterminate indicators move only while painted and hold still under reduced motion.
func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
if !opts.Indeterminate && (math.IsNaN(value) || value < 0 || value > 1) {
panic(fmt.Sprintf("mujica: Progress value %v outside 0..1", value))
}
if opts.Size < 0 {
panic("mujica: negative Progress Size")
}
k := Tokens(c)
u := Density(c).Unit()
still := c.Preferences().ReduceMotion
name := opts.Label
if name == "" {
name = msg(c, "loading")
}
acc := value
if opts.Indeterminate {
acc = -1
}
caption := ""
switch {
case opts.Indeterminate && still:
caption = msg(c, "progress.indeterminate")
case !opts.Indeterminate && opts.ShowValue:
caption = strconv.Itoa(int(math.Round(value*100))) + "%"
}
if opts.Shape == ProgressRing {
return progressRing(c, k, u, acc, name, caption, opts, still)
}
col := ui.Column(c).Gap(u * 1.5)
if opts.Size > 0 {
col.Width(opts.Size)
} else {
col.FillWidth()
}
col.Children(func() {
if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
})
}
bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border)
shown := float32(value)
if !opts.Indeterminate {
shown = bar.Animate("progress.value", shown, theme.StateDuration)
}
bar.Draw(func(p *ui.Painter, r ui.Rect) {
switch {
case !opts.Indeterminate:
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * shown, H: r.H}, k.AccentText, theme.ControlRadius)
case still:
p.Fill(r, k.AccentText.Alpha(0.4), theme.ControlRadius)
default:
p.AnimationFrame()
phase := float32(p.Now().UnixMilli()%1400) / 1400
w := r.W * 0.3
x := r.X - w + (r.W+w)*phase
p.Clip(r, theme.ControlRadius, func() {
p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, k.AccentText, theme.ControlRadius)
})
}
})
})
return col
}
func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, caption string, opts ProgressOptions, still bool) *ui.Element {
d := opts.Size
if d == 0 {
d = 48
}
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2)
row.Children(func() {
ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent)
shown := float32(acc)
if !opts.Indeterminate {
shown = ring.Animate("progress.value", shown, theme.StateDuration)
}
stroke := max(3, d/10)
ring.Draw(func(p *ui.Painter, r ui.Rect) {
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, (r.W-stroke)/2
var track ui.Path
track.Circle(cx, cy, rad)
p.StrokePath(&track, stroke, k.Border)
switch {
case !opts.Indeterminate:
if shown > 0 {
p.StrokePath(progressArc(cx, cy, rad, 0, shown), stroke, k.AccentText)
}
case still:
p.StrokePath(&track, stroke, k.AccentText.Alpha(0.4))
default:
p.AnimationFrame()
start := float32(p.Now().UnixMilli()%1200) / 1200
p.StrokePath(progressArc(cx, cy, rad, start, start+0.25), stroke, k.AccentText)
}
})
if caption != "" && d >= 44 && !opts.Indeterminate {
ring.Children(func() {
ui.Text(c, caption).FontSize(theme.CaptionSize).FontFeatures("tnum").TextColor(k.Text)
})
ring.Center()
}
if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) {
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(theme.CaptionSize)
}
})
}
})
return row
}
// progressArc is the arc from turn a to turn b, clockwise from twelve o'clock.
func progressArc(cx, cy, r float32, a, b float32) *ui.Path {
var p ui.Path
n := max(2, int(math.Ceil(float64((b-a)*64))))
for i := 0; i <= n; i++ {
t := float64(a + (b-a)*float32(i)/float32(n))
x := cx + r*float32(math.Sin(2*math.Pi*t))
y := cy - r*float32(math.Cos(2*math.Pi*t))
if i == 0 {
p.MoveTo(x, y)
} else {
p.LineTo(x, y)
}
}
return &p
}
// MeterOptions configure a Meter.
type MeterOptions struct {
// Min and Max bound the value; both zero mean 0 to 1.
Min, Max float64
// Levels mark the value High from Warning and Critical from Critical;
// Critical below Warning flags low values instead. Nil marks none.
Levels *ui.MeterLevels
Label string
ShowValue bool
// Width is the bar width; zero fills.
Width float32
}
// meterLevel is 0 normal, 1 warning, 2 critical.
func meterLevel(v float64, l *ui.MeterLevels) int {
if l == nil || l.Warning == l.Critical {
return 0
}
low := l.Critical < l.Warning
switch {
case !low && v >= l.Critical, low && v <= l.Critical:
return 2
case !low && v >= l.Warning, low && v <= l.Warning:
return 1
}
return 0
}
// Meter shows a level within a range, as disk use. Thresholds show as
// ticks and the level as a word as well as a color. It panics on a value outside the range.
func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
lo, hi := opts.Min, opts.Max
if lo == 0 && hi == 0 {
hi = 1
}
if !(hi > lo) {
panic(fmt.Sprintf("mujica: Meter range %v..%v is empty", lo, hi))
}
if math.IsNaN(value) || value < lo || value > hi {
panic(fmt.Sprintf("mujica: Meter value %v outside %v..%v", value, lo, hi))
}
if l := opts.Levels; l != nil && (l.Warning < lo || l.Warning > hi || l.Critical < lo || l.Critical > hi) {
panic("mujica: Meter levels outside the range")
}
k := Tokens(c)
u := Density(c).Unit()
level := meterLevel(value, opts.Levels)
tone := []ui.Color{k.Success, k.Warning, k.Danger}[level]
var word string
if opts.Levels != nil {
low := opts.Levels.Critical < opts.Levels.Warning
word = msg(c, "meter.normal")
switch {
case level == 2:
word = msg(c, "meter.critical")
case level == 1 && low:
word = msg(c, "meter.low_warning")
case level == 1:
word = msg(c, "meter.warning")
}
}
frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) }
col := ui.Column(c).Gap(u * 1.5)
if opts.Width > 0 {
col.Width(opts.Width)
} else {
col.FillWidth()
}
col.Children(func() {
caption := word
if opts.ShowValue {
caption = strconv.FormatFloat(value, 'f', -1, 64) + " / " + strconv.FormatFloat(hi, 'f', -1, 64)
if word != "" {
caption += " · " + word
}
}
if opts.Label != "" || caption != "" {
ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, opts.Label).TextColor(k.Text).Grow(1).Shrink(1)
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum").SingleLine()
})
}
name := opts.Label
if word != "" {
name += " (" + word + ")"
}
bar := ui.Meter(c, value, lo, hi, opts.Levels).Label(name).FillWidth().Height(u * 2).
Radius(theme.ControlRadius).Background(k.Border)
bar.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * frac(value), H: r.H}, tone, theme.ControlRadius)
})
if l := opts.Levels; l != nil {
// Ticks at the thresholds, over the fill.
bar.DrawOver(func(p *ui.Painter, r ui.Rect) {
for _, v := range []float64{l.Warning, l.Critical} {
x := float32(math.Round(float64(r.X + r.W*frac(v))))
p.Fill(ui.Rect{X: x - 1, Y: r.Y, W: 2, H: r.H}, k.Text.Alpha(0.6), 0)
}
})
}
})
return col
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestProgressValueText(t *testing.T) {
v := 0.424
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Progress(c, v, ProgressOptions{Label: "Uploading", ShowValue: true})
Progress(c, 1, ProgressOptions{Shape: ProgressRing, Label: "Indexing", ShowValue: true})
}, 400, 300)
if !tt.HasText("42%") || !tt.HasText("100%") {
t.Fatalf("texts %q", tt.Texts())
}
if r, ok := tt.Find("Indexing"); !ok || r.H < 48 {
t.Fatalf("ring %v %v", r, ok)
}
v = 0
tt.Frame()
if !tt.HasText("0%") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestProgressIndeterminateReducedMotion(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Progress(c, math.NaN(), ProgressOptions{Indeterminate: true, Label: "Connecting"})
}, 400, 300)
if tt.HasText("In progress") {
t.Fatal("static note shown while moving")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("In progress") {
t.Fatal("no static note under reduced motion")
}
a := encode(t, tt.Image())
tt.Frame()
if string(a) != string(encode(t, tt.Image())) {
t.Fatal("indeterminate bar moved under reduced motion")
}
}
func TestProgressRingSize(t *testing.T) {
var w float32
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Row(c).Children(func() {
w = Progress(c, 0.5, ProgressOptions{Shape: ProgressRing, Size: 24}).Bounds().W
})
}, 400, 300)
tt.Frame()
if w != 24 {
t.Fatalf("ring width %v", w)
}
}
func TestMeterLevels(t *testing.T) {
disk := &ui.MeterLevels{Warning: 80, Critical: 95}
battery := &ui.MeterLevels{Warning: 20, Critical: 10}
for _, tc := range []struct {
v float64
levels *ui.MeterLevels
want int
}{
{50, disk, 0}, {80, disk, 1}, {94.9, disk, 1}, {95, disk, 2}, {100, disk, 2},
{50, battery, 0}, {20, battery, 1}, {10, battery, 2}, {0, battery, 2}, {99, nil, 0},
} {
if got := meterLevel(tc.v, tc.levels); got != tc.want {
t.Errorf("meterLevel(%v, %+v) = %d, want %d", tc.v, tc.levels, got, tc.want)
}
}
}
func TestMeterWords(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
Meter(c, 90, MeterOptions{Max: 100, Levels: &ui.MeterLevels{Warning: 80, Critical: 95}, Label: "磁盘", ShowValue: true})
Meter(c, 15, MeterOptions{Max: 100, Levels: &ui.MeterLevels{Warning: 20, Critical: 10}, Label: "电量"})
}, 400, 300)
if !tt.HasText("90 / 100 · 偏高") || !tt.HasText("偏低") {
t.Fatalf("texts %q", tt.Texts())
}
if _, ok := tt.Find("磁盘 (偏高)"); !ok {
t.Fatal("meter name lacks its level")
}
}
func TestProgressAndMeterPanics(t *testing.T) {
for name, f := range map[string]func(c *ui.Context){
"progress above 1": func(c *ui.Context) { Progress(c, 1.2, ProgressOptions{}) },
"progress NaN": func(c *ui.Context) { Progress(c, math.NaN(), ProgressOptions{}) },
"meter empty range": func(c *ui.Context) { Meter(c, 5, MeterOptions{Min: 10, Max: 10}) },
"meter outside": func(c *ui.Context) { Meter(c, 2, MeterOptions{}) },
"meter levels": func(c *ui.Context) { Meter(c, 5, MeterOptions{Max: 10, Levels: &ui.MeterLevels{Warning: 8, Critical: 12}}) },
} {
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 TestProgressSnapshot(t *testing.T) {
snapshot(t, "progress", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
Progress(c, 0.62, ProgressOptions{Label: "Uploading 3 files", ShowValue: true})
Progress(c, 0, ProgressOptions{Indeterminate: true, Label: "Connecting"})
ui.Row(c).Gap(24).AlignItems(ui.Center).Children(func() {
Progress(c, 0.35, ProgressOptions{Shape: ProgressRing, ShowValue: true})
Progress(c, 0.8, ProgressOptions{Shape: ProgressRing, Size: 24, Label: "Indexing", ShowValue: true})
Progress(c, 0, ProgressOptions{Shape: ProgressRing, Indeterminate: true, Label: "Waiting"})
})
Meter(c, 42, MeterOptions{Max: 100, Label: "Memory", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}})
Meter(c, 93, MeterOptions{Max: 100, Label: "Disk", ShowValue: true, Levels: &ui.MeterLevels{Warning: 70, Critical: 90}})
})
})
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"cmp"
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ResultKind is the outcome a Result page reports. The zero value is ResultSuccess.
type ResultKind int
const (
ResultSuccess ResultKind = iota
ResultFailure
ResultForbidden
)
// ResultOptions configure a Result.
type ResultOptions struct {
Kind ResultKind
// Title uses the kind's built-in copy when empty.
Title string
Description string
// Actions label the next steps; the first is the primary button.
Actions []string
}
// ResultResult is what a Result built and reported this frame.
type ResultResult struct {
Element *ui.Element
// Action is the index of the clicked action, -1 for none.
Action int
}
// Result reports the outcome of a finished task and offers next steps.
// It only reports the chosen step; the caller navigates.
func Result(c *ui.Context, opts ResultOptions) ResultResult {
k := Tokens(c)
u := Density(c).Unit()
var tone ui.Color
var icon *ui.SVG
var key string
switch opts.Kind {
case ResultSuccess:
tone, icon, key = k.Success, icons.Must("circle-check"), "result.success.title"
case ResultFailure:
tone, icon, key = k.Danger, icons.Must("circle-x"), "result.failure.title"
case ResultForbidden:
tone, icon, key = k.Warning, icons.Must("shield-off"), "result.forbidden.title"
default:
panic(fmt.Sprintf("mujica: unknown ResultKind %d", int(opts.Kind)))
}
title := cmp.Or(opts.Title, msg(c, key))
res := ResultResult{Action: -1}
res.Element = ui.Column(c).FillWidth().AlignItems(ui.Center).Gap(u*3).Padding(u*10, u*6).Role(ui.RoleGroup).Label(title)
res.Element.Children(func() {
ui.Icon(c, icon).Size(48, 48).TextColor(tone)
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H2Size).TextColor(k.Text).TextAlign(ui.Center)
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).TextAlign(ui.Center).MaxWidth(420).LineHeight(theme.LineHeight)
}
resultOrnament(c, k)
if len(opts.Actions) == 0 {
return
}
ui.Row(c).Gap(u * 3).Wrap().Justify(ui.Center).Children(func() {
for i, a := range opts.Actions {
v := Secondary
if i == 0 {
v = Primary
}
if Button(c, a, ButtonOptions{Variant: v}).Key(i).Clicked() {
slog.Debug("mujica: result action", "kind", opts.Kind, "index", i)
res.Action = i
}
}
})
})
return res
}
// resultOrnament draws two hairlines meeting a diamond, in ornament gold.
func resultOrnament(c *ui.Context, k theme.Tokens) {
ui.Box(c).Size(160, 12).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
cy := float32(int(r.Y+r.H/2)) + 0.5
cx := r.X + r.W/2
p.Line(r.X, cy, cx-9, cy, 1, k.Ornament)
p.Line(cx+9, cy, r.X+r.W, cy, 1, k.Ornament)
p.FillPath(internal.Diamond(cx, cy, 8), k.Ornament)
})
}
+66
View File
@@ -0,0 +1,66 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestResultActions(t *testing.T) {
got := []int{}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if a := Result(c, ResultOptions{Kind: ResultFailure, Description: "The payment was declined.", Actions: []string{"Try again", "Back to cart"}}).Action; a >= 0 {
got = append(got, a)
}
}, 600, 500)
if !tt.HasText("Something went wrong") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Back to cart"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 2 || got[0] != 1 || got[1] != 0 {
t.Fatalf("actions %v", got)
}
}
func TestResultTitles(t *testing.T) {
for kind, want := range map[ResultKind]string{ResultSuccess: "操作成功", ResultFailure: "操作失败", ResultForbidden: "无访问权限"} {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
Result(c, ResultOptions{Kind: kind})
}, 500, 400)
if !tt.HasText(want) {
t.Errorf("%d: texts %q", kind, tt.Texts())
}
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Result(c, ResultOptions{Title: "Order placed"})
}, 500, 400)
if !tt.HasText("Order placed") || tt.HasText("Done") {
t.Fatalf("custom title: %q", tt.Texts())
}
}
func TestResultPanicsOnUnknownKind(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Result(c, ResultOptions{Kind: 7}) }, 300, 200)
}
func TestResultSnapshot(t *testing.T) {
snapshot(t, "result", 520, 340, func(c *ui.Context) {
Result(c, ResultOptions{
Kind: ResultForbidden,
Description: "You need the Editor role to change billing settings.",
Actions: []string{"Request access", "Go back"},
})
})
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// SkeletonShape is the placeholder's form. The zero value is SkeletonText.
type SkeletonShape int
const (
SkeletonText SkeletonShape = iota
SkeletonCircle
SkeletonRect
)
// SkeletonOptions configure a Skeleton. Sizes are fixed so content does not shift.
type SkeletonOptions struct {
Shape SkeletonShape
// Width fills the parent when zero; a circle uses it as its diameter (40 DIP when zero).
Width float32
// Height is the rect height (ControlHeight when zero); text derives it from Lines.
Height float32
// Lines is the text line count, 1 when zero; the last of several is shorter.
Lines int
}
// Skeleton stands in for content that is loading. It pulses while painted
// and shows a static fill under reduced motion.
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
if opts.Width < 0 || opts.Height < 0 || opts.Lines < 0 {
panic("mujica: negative Skeleton size")
}
k := Tokens(c)
still := c.Preferences().ReduceMotion
fill := k.SurfaceHover
if k.Dark {
fill = k.Border
}
paint := func(p *ui.Painter, r ui.Rect, radius float32) {
a := float32(1)
if !still {
p.AnimationFrame()
t := float64(p.Now().UnixMilli()%1600) / 1600
a = 0.55 + 0.45*float32(0.5+0.5*math.Cos(2*math.Pi*t))
}
p.Fill(r, fill.Alpha(a), radius)
}
e := ui.Box(c).Role(ui.RoleNone)
if opts.Width > 0 {
e.Width(opts.Width)
} else if opts.Shape != SkeletonCircle {
e.FillWidth()
}
switch opts.Shape {
case SkeletonCircle:
d := opts.Width
if d == 0 {
d = 40
}
e.Size(d, d).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, r.W/2) })
case SkeletonRect:
h := opts.Height
if h == 0 {
h = Density(c).ControlHeight()
}
e.Height(h).Draw(func(p *ui.Painter, r ui.Rect) { paint(p, r, theme.CardRadius) })
default:
lines := max(opts.Lines, 1)
line := theme.BodySize * theme.LineHeight
e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) {
bar := theme.BodySize * 0.8
for i := range lines {
w := r.W
if lines > 1 && i == lines-1 {
w *= 0.6
}
y := r.Y + float32(i)*line + (line-bar)/2
paint(p, ui.Rect{X: r.X, Y: y, W: w, H: bar}, theme.ControlRadius)
}
})
}
return e
}
+83
View File
@@ -0,0 +1,83 @@
package mujica
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestSkeletonSizes(t *testing.T) {
var text, circle, rect, fixed ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Density: theme.Compact})
ui.Column(c).Width(300).AlignItems(ui.Start).Children(func() {
text = Skeleton(c, SkeletonOptions{Lines: 3}).Bounds()
circle = Skeleton(c, SkeletonOptions{Shape: SkeletonCircle}).Bounds()
rect = Skeleton(c, SkeletonOptions{Shape: SkeletonRect}).Bounds()
fixed = Skeleton(c, SkeletonOptions{Shape: SkeletonRect, Width: 120, Height: 80}).Bounds()
})
}, 400, 400)
tt.Frame()
if text.W != 300 || text.H != 63 {
t.Errorf("text %v", text)
}
if circle.W != 40 || circle.H != 40 {
t.Errorf("circle %v", circle)
}
if rect.W != 300 || rect.H != 28 {
t.Errorf("rect %v", rect)
}
if fixed.W != 120 || fixed.H != 80 {
t.Errorf("fixed %v", fixed)
}
}
func TestSkeletonStaticUnderReducedMotion(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Skeleton(c, SkeletonOptions{Shape: SkeletonRect})
}, 200, 100)
a := encode(t, tt.Image())
time.Sleep(300 * time.Millisecond)
tt.Frame()
if bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("skeleton did not pulse")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
a = encode(t, tt.Image())
time.Sleep(300 * time.Millisecond)
tt.Frame()
if !bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("skeleton moved under reduced motion")
}
}
func TestSkeletonPanicsOnNegativeSize(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Skeleton(c, SkeletonOptions{Lines: -1})
}, 200, 100)
}
func TestSkeletonSnapshot(t *testing.T) {
snapshot(t, "skeleton", 420, 200, func(c *ui.Context) {
ui.Column(c).Padding(20).Gap(16).Children(func() {
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
Skeleton(c, SkeletonOptions{Shape: SkeletonCircle})
ui.Column(c).Grow(1).Children(func() {
Skeleton(c, SkeletonOptions{Lines: 2})
})
})
Skeleton(c, SkeletonOptions{Shape: SkeletonRect, Height: 72})
})
})
}
+4 -2
View File
@@ -19,6 +19,7 @@ type SpinnerOptions struct {
}
// Spinner draws four diamonds turning around a center while it shows.
// It repaints only while painted: hidden, it stops. Reduced motion holds it still.
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
k := Tokens(c)
size := opts.Size
@@ -34,9 +35,10 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
color = k.AccentText
}
still := c.Preferences().ReduceMotion
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleProgress).Label(label)
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center)
row.Children(func() {
ui.Box(c).Size(size, size).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
// MyGo's spinner carries the indeterminate progress role.
ui.Spinner(c).Label(label).Size(size, size).Draw(func(p *ui.Painter, r ui.Rect) {
turn := 0.0
if !still {
turn = float64(p.Now().UnixMilli()%1200) / 1200
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestSpinnerLabelAndSize(t *testing.T) {
var mark ui.Rect
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
Spinner(c, SpinnerOptions{})
Spinner(c, SpinnerOptions{Size: 32, Label: "Fetching mail", ShowLabel: true})
}, 400, 300)
r, ok := tt.Find("加载中")
if !ok || r.W != 16 || r.H != 16 {
t.Fatalf("default mark %v %v", r, ok)
}
if tt.HasText("加载中") {
t.Fatal("hidden label painted")
}
mark, ok = tt.Find("Fetching mail")
if !ok || mark.W != 32 {
t.Fatalf("sized mark %v %v", mark, ok)
}
n := 0
for _, s := range tt.Texts() {
if s == "Fetching mail" {
n++
}
}
if n != 2 {
t.Fatalf("label as name and text: %d of %q", n, tt.Texts())
}
}
func TestSpinnerMotion(t *testing.T) {
show := true
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if show {
Spinner(c, SpinnerOptions{Size: 40})
}
}, 100, 100)
a := encode(t, tt.Image())
time.Sleep(150 * time.Millisecond)
tt.Frame()
if bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("spinner did not turn")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
a = encode(t, tt.Image())
time.Sleep(150 * time.Millisecond)
tt.Frame()
if !bytes.Equal(a, encode(t, tt.Image())) {
t.Fatal("spinner turned under reduced motion")
}
show = false
tt.Frame()
if _, ok := tt.Find("Loading"); ok {
t.Fatal("hidden spinner still built")
}
}
func TestSpinnerSnapshot(t *testing.T) {
snapshot(t, "spinner", 360, 100, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Center).Children(func() {
Spinner(c, SpinnerOptions{})
Spinner(c, SpinnerOptions{Size: 24})
Spinner(c, SpinnerOptions{Size: 20, Label: "Syncing", ShowLabel: true})
Button(c, "Saving", ButtonOptions{Loading: true})
})
})
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Status is a system or item state. The zero value is StatusSuccess.
type Status int
const (
StatusSuccess Status = iota
StatusWarning
StatusError
StatusOffline
StatusBusy
)
func (s Status) String() string {
switch s {
case StatusSuccess:
return "success"
case StatusWarning:
return "warning"
case StatusError:
return "error"
case StatusOffline:
return "offline"
case StatusBusy:
return "busy"
}
panic(fmt.Sprintf("mujica: unknown Status %d", int(s)))
}
// StatusIndicatorOptions configure a StatusIndicator.
type StatusIndicatorOptions struct {
// Label replaces the status's built-in word, as "Database online".
Label string
}
// 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 {
k := Tokens(c)
u := Density(c).Unit()
label := opts.Label
if label == "" {
label = msg(c, "status_indicator."+status.String())
}
var tone ui.Color
var icon *ui.SVG
switch status {
case StatusSuccess:
tone, icon = k.Success, icons.Must("circle-check")
case StatusWarning:
tone, icon = k.Warning, icons.Must("triangle-alert")
case StatusError:
tone, icon = k.Danger, icons.Must("circle-x")
case StatusOffline:
tone, icon = k.TextMuted, icons.Must("wifi-off")
case StatusBusy:
tone = k.Info
}
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 1.5).Role(ui.RoleStatus).Label(label)
row.Children(func() {
if status == StatusBusy {
Spinner(c, SpinnerOptions{Size: 16, Color: tone, Label: label})
} else {
ui.Icon(c, icon).Size(16, 16).TextColor(tone)
}
ui.Text(c, label).TextColor(k.Text).FontSize(theme.BodySize).SingleLine()
})
return row
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestStatusIndicatorWords(t *testing.T) {
want := map[Status]string{
StatusSuccess: "Healthy", StatusWarning: "Degraded", StatusError: "Failing",
StatusOffline: "Offline", StatusBusy: "Working",
}
for s, word := range want {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
StatusIndicator(c, s, StatusIndicatorOptions{})
}, 300, 100)
if !tt.HasText(word) {
t.Errorf("%v: texts %q", s, tt.Texts())
}
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: ZhCN})
StatusIndicator(c, StatusOffline, StatusIndicatorOptions{})
StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{Label: "Database online"})
}, 300, 100)
if !tt.HasText("离线") || !tt.HasText("Database online") || tt.HasText("正常") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestStatusIndicatorShapesDiffer(t *testing.T) {
// The same tone would still differ by shape: render each in one color.
var imgs [][]byte
for _, s := range []Status{StatusSuccess, StatusWarning, StatusError, StatusOffline} {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
StatusIndicator(c, s, StatusIndicatorOptions{Label: "Same"})
}, 120, 40)
imgs = append(imgs, encode(t, tt.Image()))
}
for i := range imgs {
for j := i + 1; j < len(imgs); j++ {
if string(imgs[i]) == string(imgs[j]) {
t.Errorf("statuses %d and %d draw alike", i, j)
}
}
}
}
func TestStatusIndicatorPanicsOnUnknown(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); StatusIndicator(c, 42, StatusIndicatorOptions{}) }, 300, 100)
}
func TestStatusIndicatorSnapshot(t *testing.T) {
snapshot(t, "status_indicator", 560, 80, func(c *ui.Context) {
ui.Row(c).Padding(24).Gap(20).Children(func() {
for _, s := range []Status{StatusSuccess, StatusWarning, StatusError, StatusOffline, StatusBusy} {
StatusIndicator(c, s, StatusIndicatorOptions{})
}
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+257
View File
@@ -0,0 +1,257 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Toast timing and stacking per window.
const (
ToastDuration = 5 * time.Second
ToastVisible = 3
)
// ToastMessage is one toast. Message is required.
type ToastMessage struct {
// ID dedupes: showing an ID already queued replaces it. Empty uses Message.
ID string
Severity Severity
Message string
// Action labels a button; Toaster reports its click by ID.
Action string
// Duration is ToastDuration when zero; errors then stay until closed.
Duration time.Duration
}
// ToasterResult reports the toast events of a frame by message ID.
type ToasterResult struct {
Action string
Closed string
}
type toastItem struct {
msg ToastMessage
life time.Duration // zero stays until closed
spent time.Duration
runFrom time.Time // zero while paused or queued
visible bool
announced bool
}
// toastQueue holds a window's toasts, visible ones first in order shown.
type toastQueue struct{ items []toastItem }
type toastKey struct{}
func toastLife(m ToastMessage) time.Duration {
switch {
case m.Duration < 0:
panic("mujica: negative toast Duration")
case m.Duration > 0:
return m.Duration
case m.Severity == SeverityError:
return 0
}
return ToastDuration
}
func (q *toastQueue) push(m ToastMessage, now time.Time) {
if m.Message == "" {
panic("mujica: toast needs a Message")
}
if m.ID == "" {
m.ID = m.Message
}
for i := range q.items {
it := &q.items[i]
if it.msg.ID != m.ID {
continue
}
it.msg, it.life, it.spent, it.announced = m, toastLife(m), 0, false
if it.visible && !it.runFrom.IsZero() {
it.runFrom = now
}
slog.Debug("mujica: toast replaced", "id", m.ID)
return
}
q.items = append(q.items, toastItem{msg: m, life: toastLife(m)})
slog.Debug("mujica: toast queued", "id", m.ID)
q.promote(now)
}
// promote shows queued toasts while fewer than ToastVisible show.
func (q *toastQueue) promote(now time.Time) {
n := 0
for i := range q.items {
it := &q.items[i]
if !it.visible && n < ToastVisible {
it.visible, it.runFrom = true, now
slog.Debug("mujica: toast shown", "id", it.msg.ID)
}
if it.visible {
n++
}
}
}
func (it *toastItem) elapsed(now time.Time) time.Duration {
if it.runFrom.IsZero() {
return it.spent
}
return it.spent + now.Sub(it.runFrom)
}
// expire drops toasts whose time ran out and returns the wait until the next does.
func (q *toastQueue) expire(now time.Time) (next time.Duration) {
live := q.items[:0]
for _, it := range q.items {
if it.visible && it.life > 0 && it.elapsed(now) >= it.life {
slog.Debug("mujica: toast expired", "id", it.msg.ID)
continue
}
live = append(live, it)
}
q.items = live
q.promote(now)
for _, it := range q.items {
if it.visible && it.life > 0 && !it.runFrom.IsZero() {
left := it.life - it.elapsed(now)
if next == 0 || left < next {
next = left
}
}
}
return next
}
func (q *toastQueue) pause(id string, paused bool, now time.Time) {
for i := range q.items {
it := &q.items[i]
if it.msg.ID != id || !it.visible {
continue
}
switch {
case paused && !it.runFrom.IsZero():
it.spent, it.runFrom = it.elapsed(now), time.Time{}
slog.Debug("mujica: toast paused", "id", id)
case !paused && it.runFrom.IsZero():
it.runFrom = now
slog.Debug("mujica: toast resumed", "id", id)
}
}
}
func (q *toastQueue) remove(id string, now time.Time) {
for i, it := range q.items {
if it.msg.ID == id {
q.items = append(q.items[:i], q.items[i+1:]...)
break
}
}
q.promote(now)
}
func toasts(c *ui.Context) *toastQueue {
return ui.Local(c.Root(), toastKey{}, func() toastQueue { return toastQueue{} })
}
// ShowToast queues m in the window. Call it on the UI thread.
func ShowToast(c *ui.Context, m ToastMessage) {
toasts(c).push(m, c.Now())
c.Invalidate()
}
// DismissToast closes the toast with id, shown or queued.
func DismissToast(c *ui.Context, id string) {
toasts(c).remove(id, c.Now())
c.Invalidate()
}
// Toaster draws the window's toasts at its bottom right. Call it once per
// frame, last in the view. Toasts never take the focus; Tab reaches their
// buttons. Hover or focus inside a toast pauses its time.
func Toaster(c *ui.Context) ToasterResult {
q := toasts(c)
now := c.Now()
if next := q.expire(now); next > 0 {
c.After(next)
}
var res ToasterResult
if len(q.items) == 0 {
return res
}
k := Tokens(c)
u := Density(c).Unit()
w, _ := c.Size()
ui.Overlay(c, func() {
stack := ui.Column(c).Absolute().Right(u * 4).Bottom(u * 4).Gap(u * 2).AlignItems(ui.End).
PassThrough().Role(ui.RoleGroup).Label(msg(c, "toast.region"))
stack.Children(func() {
for i := range q.items {
it := q.items[i]
if !it.visible {
continue
}
if !it.announced {
q.items[i].announced = true
c.Announce(it.msg.Message)
}
action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg)
q.pause(it.msg.ID, held, now)
switch {
case action:
res.Action = it.msg.ID
slog.Debug("mujica: toast action", "id", it.msg.ID)
case closed:
res.Closed = it.msg.ID
slog.Debug("mujica: toast closed", "id", it.msg.ID)
}
}
})
})
for _, id := range []string{res.Action, res.Closed} {
if id != "" {
q.remove(id, now)
c.Invalidate()
}
}
return res
}
// toastCard builds one toast and reports its events and whether hover or focus holds it.
func toastCard(c *ui.Context, k theme.Tokens, u, width float32, m ToastMessage) (action, closed, held bool) {
tone, icon := severityLook(k, m.Severity)
card := ui.Row(c).Key(m.ID).Width(width).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
Role(ui.RoleStatus).Label(m.Message)
card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14))
card.Transition(ui.ElementTransition{
Duration: theme.PopupDuration,
Enter: &ui.Motion{Y: 8},
Exit: &ui.Motion{},
})
card.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + theme.PopupRadius, W: 3, H: r.H - 2*theme.PopupRadius}, tone, 0)
})
card.Children(func() {
ui.Icon(c, icon).Size(20, 20).TextColor(tone)
ui.Text(c, m.Message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight)
if m.Action != "" && Button(c, m.Action, ButtonOptions{Variant: Ghost}).Clicked() {
action = true
}
x := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "toast.close")})
x.TextColor(k.TextMuted).Padding(0, u)
if x.Clicked() {
closed = true
}
})
if card.Shortcut(0, ui.KeyEscape) {
closed = true
}
return action, closed, card.Hovered() || card.FocusWithin()
}
+193
View File
@@ -0,0 +1,193 @@
package mujica
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func toastIDs(q *toastQueue, visible bool) []string {
var ids []string
for _, it := range q.items {
if it.visible == visible {
ids = append(ids, it.msg.ID)
}
}
return ids
}
func TestToastQueueLimitsVisible(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
for _, id := range []string{"a", "b", "c", "d", "e"} {
q.push(ToastMessage{ID: id, Message: id}, t0)
}
if v, w := toastIDs(&q, true), toastIDs(&q, false); !slices.Equal(v, []string{"a", "b", "c"}) || !slices.Equal(w, []string{"d", "e"}) {
t.Fatalf("visible %v waiting %v", v, w)
}
// d starts its 5 s only when it shows.
if next := q.expire(t0.Add(ToastDuration)); next != ToastDuration {
t.Fatalf("next wake %v", next)
}
if v := toastIDs(&q, true); !slices.Equal(v, []string{"d", "e"}) {
t.Fatalf("after expiry visible %v", v)
}
q.expire(t0.Add(2*ToastDuration - time.Millisecond))
if len(q.items) != 2 {
t.Fatalf("expired early: %v", toastIDs(&q, true))
}
q.expire(t0.Add(2 * ToastDuration))
if len(q.items) != 0 {
t.Fatalf("left %v", toastIDs(&q, true))
}
}
func TestToastQueueDedupesByID(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "sync", Message: "Syncing 1 file"}, t0)
q.push(ToastMessage{Message: "Saved"}, t0)
q.push(ToastMessage{ID: "sync", Message: "Syncing 2 files"}, t0.Add(4*time.Second))
q.push(ToastMessage{Message: "Saved"}, t0.Add(4*time.Second))
if len(q.items) != 2 || q.items[0].msg.Message != "Syncing 2 files" || q.items[1].msg.ID != "Saved" {
t.Fatalf("items %+v", q.items)
}
// The replaced toast restarted its time.
q.expire(t0.Add(6 * time.Second))
if len(q.items) != 2 {
t.Fatalf("replacement kept the old time: %d left", len(q.items))
}
}
func TestToastQueueErrorsStayAndPauseHolds(t *testing.T) {
var q toastQueue
t0 := time.Unix(0, 0)
q.push(ToastMessage{ID: "e", Severity: SeverityError, Message: "Failed"}, t0)
q.push(ToastMessage{ID: "w", Severity: SeverityWarning, Message: "Slow", Action: "Details"}, t0)
q.pause("w", true, t0.Add(4*time.Second))
if next := q.expire(t0.Add(time.Hour)); next != 0 {
t.Fatalf("paused toast still counts down: %v", next)
}
if !slices.Equal(toastIDs(&q, true), []string{"e", "w"}) {
t.Fatalf("visible %v", toastIDs(&q, true))
}
q.pause("w", false, t0.Add(time.Hour))
if next := q.expire(t0.Add(time.Hour)); next != time.Second {
t.Fatalf("resumed toast left %v, want 1s", next)
}
q.expire(t0.Add(time.Hour + time.Second))
if !slices.Equal(toastIDs(&q, true), []string{"e"}) {
t.Fatalf("visible %v", toastIDs(&q, true))
}
}
func TestToastQueuePanics(t *testing.T) {
for _, m := range []ToastMessage{{}, {Message: "x", Duration: -1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", m)
}
}()
var q toastQueue
q.push(m, time.Now())
}()
}
}
func toastTester(m ToastMessage, res *ToasterResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Notify", ButtonOptions{}).Clicked() {
ShowToast(c, m)
}
if r := Toaster(c); r != (ToasterResult{}) {
*res = r
}
}, 600, 400)
}
func TestToastActionAndClose(t *testing.T) {
var res ToasterResult
tt := toastTester(ToastMessage{ID: "del", Message: "Note deleted", Action: "Undo"}, &res)
if err := tt.Click("Notify"); err != nil {
t.Fatal(err)
}
if !tt.HasText("Note deleted") {
t.Fatal("toast not shown")
}
if err := tt.Click("Undo"); err != nil {
t.Fatal(err)
}
if res.Action != "del" || tt.HasText("Note deleted") {
t.Fatalf("after undo %+v, still shown %v", res, tt.HasText("Note deleted"))
}
res = ToasterResult{}
_ = tt.Click("Notify")
if err := tt.Click("Dismiss notification"); err != nil {
t.Fatal(err)
}
if res.Closed != "del" || tt.HasText("Note deleted") {
t.Fatalf("after close %+v", res)
}
}
func TestToastKeepsFocusAndEscapeCloses(t *testing.T) {
var res ToasterResult
tt := toastTester(ToastMessage{ID: "s", Message: "Saved"}, &res)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Saved") || !tt.Focused("Notify") {
t.Fatalf("shown %v, focus kept %v", tt.HasText("Saved"), tt.Focused("Notify"))
}
if a := tt.Announcements(); !slices.Contains(a, "Saved") {
t.Fatalf("announcements %q", a)
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Dismiss notification") {
t.Fatal("Tab does not reach the toast")
}
tt.Key(0, ui.KeyEscape)
if res.Closed != "s" || tt.HasText("Saved") {
t.Fatalf("Escape: %+v", res)
}
}
func TestToastExpiresAndHoverPauses(t *testing.T) {
var res ToasterResult
tt := toastTester(ToastMessage{Message: "Copied", Duration: 40 * time.Millisecond}, &res)
_ = tt.Click("Notify")
time.Sleep(60 * time.Millisecond)
tt.Frame()
if tt.HasText("Copied") {
t.Fatal("toast outlived its duration")
}
_ = tt.Click("Notify")
r, _ := tt.Find("Copied")
tt.Move(r.X+r.W/2, r.Y+r.H/2)
time.Sleep(60 * time.Millisecond)
tt.Frame()
if !tt.HasText("Copied") {
t.Fatal("hovered toast expired")
}
tt.Move(5, 395)
time.Sleep(60 * time.Millisecond)
tt.Frame()
if tt.HasText("Copied") {
t.Fatal("toast stayed after the pointer left")
}
}
func TestToastSnapshot(t *testing.T) {
snapshot(t, "toast", 480, 300, func(c *ui.Context) {
if len(toasts(c).items) == 0 {
ShowToast(c, ToastMessage{ID: "1", Message: "Settings saved", Severity: SeveritySuccess})
ShowToast(c, ToastMessage{ID: "2", Message: "Note moved to trash", Action: "Undo"})
ShowToast(c, ToastMessage{ID: "3", Message: "Upload failed: network lost", Severity: SeverityError})
ShowToast(c, ToastMessage{ID: "4", Message: "Queued and not shown"})
}
Toaster(c)
})
}