diff --git a/alert.go b/alert.go new file mode 100644 index 0000000..2b5f116 --- /dev/null +++ b/alert.go @@ -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 +} diff --git a/alert_test.go b/alert_test.go new file mode 100644 index 0000000..af93006 --- /dev/null +++ b/alert_test.go @@ -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"}) + }) + }) +} diff --git a/empty_state.go b/empty_state.go new file mode 100644 index 0000000..67ca961 --- /dev/null +++ b/empty_state.go @@ -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 +} diff --git a/empty_state_test.go b/empty_state_test.go new file mode 100644 index 0000000..246b611 --- /dev/null +++ b/empty_state_test.go @@ -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"}) + }) + }) + }) +} diff --git a/loading_overlay.go b/loading_overlay.go new file mode 100644 index 0000000..6f8ba0d --- /dev/null +++ b/loading_overlay.go @@ -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 +} diff --git a/messages_feedback.go b/messages_feedback.go index c7ec2d0..527754c 100644 --- a/messages_feedback.go +++ b/messages_feedback.go @@ -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…", "正在加载…"}, }) } diff --git a/notification_center.go b/notification_center.go new file mode 100644 index 0000000..7b2cc8c --- /dev/null +++ b/notification_center.go @@ -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 +} diff --git a/progress.go b/progress.go new file mode 100644 index 0000000..c0059af --- /dev/null +++ b/progress.go @@ -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 +} diff --git a/progress_test.go b/progress_test.go new file mode 100644 index 0000000..de54799 --- /dev/null +++ b/progress_test.go @@ -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}}) + }) + }) +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..70814d0 --- /dev/null +++ b/result.go @@ -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) + }) +} diff --git a/result_test.go b/result_test.go new file mode 100644 index 0000000..b7ec69f --- /dev/null +++ b/result_test.go @@ -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"}, + }) + }) +} diff --git a/skeleton.go b/skeleton.go new file mode 100644 index 0000000..eefa1b5 --- /dev/null +++ b/skeleton.go @@ -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 +} diff --git a/skeleton_test.go b/skeleton_test.go new file mode 100644 index 0000000..a9b2661 --- /dev/null +++ b/skeleton_test.go @@ -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}) + }) + }) +} diff --git a/spinner.go b/spinner.go index b93ca02..bb4ef74 100644 --- a/spinner.go +++ b/spinner.go @@ -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 diff --git a/spinner_test.go b/spinner_test.go new file mode 100644 index 0000000..47a7abb --- /dev/null +++ b/spinner_test.go @@ -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}) + }) + }) +} diff --git a/status_indicator.go b/status_indicator.go new file mode 100644 index 0000000..045c642 --- /dev/null +++ b/status_indicator.go @@ -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 +} diff --git a/status_indicator_test.go b/status_indicator_test.go new file mode 100644 index 0000000..b47df01 --- /dev/null +++ b/status_indicator_test.go @@ -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{}) + } + }) + }) +} diff --git a/testdata/snapshots/darwin/alert-dark.png b/testdata/snapshots/darwin/alert-dark.png new file mode 100644 index 0000000..ff9faed Binary files /dev/null and b/testdata/snapshots/darwin/alert-dark.png differ diff --git a/testdata/snapshots/darwin/alert-light.png b/testdata/snapshots/darwin/alert-light.png new file mode 100644 index 0000000..c34f4df Binary files /dev/null and b/testdata/snapshots/darwin/alert-light.png differ diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png new file mode 100644 index 0000000..9d537d3 Binary files /dev/null and b/testdata/snapshots/darwin/progress-dark.png differ diff --git a/testdata/snapshots/darwin/progress-light.png b/testdata/snapshots/darwin/progress-light.png new file mode 100644 index 0000000..9d380a6 Binary files /dev/null and b/testdata/snapshots/darwin/progress-light.png differ diff --git a/testdata/snapshots/darwin/toast-dark.png b/testdata/snapshots/darwin/toast-dark.png new file mode 100644 index 0000000..b7888e8 Binary files /dev/null and b/testdata/snapshots/darwin/toast-dark.png differ diff --git a/testdata/snapshots/darwin/toast-light.png b/testdata/snapshots/darwin/toast-light.png new file mode 100644 index 0000000..108f5fc Binary files /dev/null and b/testdata/snapshots/darwin/toast-light.png differ diff --git a/toast.go b/toast.go new file mode 100644 index 0000000..e15a6d4 --- /dev/null +++ b/toast.go @@ -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() +} diff --git a/toast_test.go b/toast_test.go new file mode 100644 index 0000000..d4425ba --- /dev/null +++ b/toast_test.go @@ -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) + }) +}