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() {
|
func init() {
|
||||||
addMessages(map[string][2]string{
|
addMessages(map[string][2]string{
|
||||||
"loading": {"Loading", "加载中"},
|
"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.
|
// 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 {
|
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
size := opts.Size
|
size := opts.Size
|
||||||
@@ -34,9 +35,10 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
|
|||||||
color = k.AccentText
|
color = k.AccentText
|
||||||
}
|
}
|
||||||
still := c.Preferences().ReduceMotion
|
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() {
|
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
|
turn := 0.0
|
||||||
if !still {
|
if !still {
|
||||||
turn = float64(p.Now().UnixMilli()%1200) / 1200
|
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