WIP feedback
This commit is contained in:
25 files changed
+2141
-2
No files matched your search
@@ -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
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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…", "正在加载…"},
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user