diff --git a/loading_overlay_test.go b/loading_overlay_test.go new file mode 100644 index 0000000..d3adc7b --- /dev/null +++ b/loading_overlay_test.go @@ -0,0 +1,119 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +type overlayProbe struct { + loading bool + saves, outside int + cancels int + cancel, label string +} + +func overlayTester(p *overlayProbe) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(12).Children(func() { + r := LoadingOverlay(c, p.loading, LoadingOverlayOptions{Cancel: p.cancel, Label: p.label}, func() { + ui.Column(c).Padding(24).Children(func() { + if Button(c, "Save", ButtonOptions{}).Clicked() { + p.saves++ + } + }) + }) + if r.Canceled { + p.cancels++ + } + if Button(c, "Outside", ButtonOptions{}).Clicked() { + p.outside++ + } + }) + }, 500, 400) +} + +func TestLoadingOverlayBlocksOnlyItsRegion(t *testing.T) { + p := &overlayProbe{} + tt := overlayTester(p) + _ = tt.Click("Save") + p.loading = true + tt.Frame() + r, _ := tt.Find("Save") + tt.ClickAt(r.X+r.W/2, r.Y+r.H/2) + tt.Key(0, ui.KeyEnter) + if err := tt.Click("Outside"); err != nil { + t.Fatal(err) + } + if p.saves != 1 || p.outside != 1 { + t.Fatalf("saves %d outside %d", p.saves, p.outside) + } + if !tt.HasText("Loading…") { + t.Fatalf("texts %q", tt.Texts()) + } + if a := tt.Announcements(); !slices.Contains(a, "Loading…") { + t.Fatalf("announcements %q", a) + } + p.loading = false + tt.Frame() + _ = tt.Click("Save") + if p.saves != 2 || tt.HasText("Loading…") { + t.Fatalf("after loading saves %d", p.saves) + } +} + +func TestLoadingOverlayCancelAndFocus(t *testing.T) { + p := &overlayProbe{cancel: "Stop", label: "Importing"} + tt := overlayTester(p) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Save") { + t.Fatal("Save not focused") + } + p.loading = true + tt.Frame() + if !tt.Focused("Stop") { + t.Fatal("focus left the region instead of moving to cancel") + } + tt.Key(0, ui.KeyEscape) + if p.cancels != 1 { + t.Fatalf("Escape cancels %d", p.cancels) + } + if err := tt.Click("Stop"); err != nil { + t.Fatal(err) + } + if p.cancels != 2 || p.saves != 0 { + t.Fatalf("cancels %d saves %d", p.cancels, p.saves) + } +} + +func TestLoadingOverlayFocusWithoutCancel(t *testing.T) { + p := &overlayProbe{} + tt := overlayTester(p) + tt.Key(0, ui.KeyTab) + p.loading = true + tt.Frame() + tt.Frame() + if !tt.Focused("Loading…") { + t.Fatal("focus not kept in the region") + } + tt.Key(0, ui.KeyEscape) + if p.cancels != 0 { + t.Fatal("Escape canceled without a cancel action") + } +} + +func TestLoadingOverlaySnapshot(t *testing.T) { + snapshot(t, "loading_overlay", 420, 220, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + LoadingOverlay(c, true, LoadingOverlayOptions{Cancel: "Cancel"}, func() { + ui.Column(c).Padding(16).Gap(8).Background(Tokens(c).Surface).Radius(4).Children(func() { + ui.Text(c, "Quarterly report") + ui.Text(c, "Revenue, churn and pipeline for Q3.") + Button(c, "Export", ButtonOptions{}) + }) + }) + }) + }) +} diff --git a/notification_center.go b/notification_center.go index 7b2cc8c..19a4f93 100644 --- a/notification_center.go +++ b/notification_center.go @@ -66,7 +66,7 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC 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). + panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).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() { @@ -98,7 +98,7 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC }) return } - list := ui.Scroll(c).Grow(1).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal) + list := ui.Scroll(c).Shrink(1).FillWidth().Role(ui.RoleList).FocusGroup(ui.Vertical | ui.Horizontal) list.Dividers(theme.BorderWidth, k.Border) list.Children(func() { for i := range *items { @@ -124,7 +124,7 @@ func notificationRow(c *ui.Context, k theme.Tokens, u float32, n *Notification, 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) + Role(ui.RoleListItem) if row.Hovered() { row.Background(k.SurfaceHover) } diff --git a/notification_center_test.go b/notification_center_test.go new file mode 100644 index 0000000..20fef80 --- /dev/null +++ b/notification_center_test.go @@ -0,0 +1,130 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func sampleNotifications() []Notification { + return []Notification{ + {ID: "a", Title: "Build failed", Body: "main · 3 tests failing", Time: "2 min ago", Action: "View log"}, + {ID: "b", Title: "Invite accepted", Body: "Mira joined the workspace", Time: "1 h ago"}, + {ID: "c", Title: "Backup complete", Time: "Yesterday", Read: true}, + } +} + +func notificationTester(items *[]Notification, last *NotificationCenterResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(560).Children(func() { + r := NotificationCenter(c, items, NotificationCenterOptions{}) + r.Element = nil + if r != (NotificationCenterResult{}) { + *last = r + } + }) + }, 640, 600) +} + +func TestNotificationCenterReadAndAction(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + if !tt.HasText("2 unread") { + t.Fatalf("texts %q", tt.Texts()) + } + if err := tt.Click("Unread: Invite accepted"); err != nil { + t.Fatal(err) + } + if !items[1].Read || last.Opened != "b" || !tt.HasText("1 unread") { + t.Fatalf("open: %+v %+v", items[1], last) + } + if err := tt.Click("View log"); err != nil { + t.Fatal(err) + } + if last.Action != "a" || items[0].Read { + t.Fatalf("action: %+v %+v", last, items[0]) + } + if err := tt.Click("Mark unread"); err != nil { + t.Fatal(err) + } + if items[1].Read || last.Toggled != "b" { + t.Fatalf("toggle: %+v %+v", items[1], last) + } + if err := tt.Click("Mark all read"); err != nil { + t.Fatal(err) + } + for _, n := range items { + if !n.Read { + t.Fatalf("mark all: %+v", n) + } + } + if !last.MarkedAllRead || tt.HasText("1 unread") || tt.HasText("2 unread") { + t.Fatalf("mark all: %+v %q", last, tt.Texts()) + } +} + +func TestNotificationCenterRemoveAndClear(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].ID != "b" || last.Removed != "a" { + t.Fatalf("remove: %+v %+v", items, last) + } + if err := tt.Click("Clear all"); err != nil { + t.Fatal(err) + } + if len(items) != 0 || !last.ClearedAll || !tt.HasText("You're all caught up") { + t.Fatalf("clear all: %+v %q", items, tt.Texts()) + } +} + +func TestNotificationCenterKeyboard(t *testing.T) { + items := sampleNotifications() + var last NotificationCenterResult + tt := notificationTester(&items, &last) + tt.Key(0, ui.KeyTab) // Mark all read + tt.Key(0, ui.KeyTab) // Clear all + tt.Key(0, ui.KeyTab) // first notification + if !tt.Focused("Unread: Build failed") { + t.Fatal("Tab did not enter the list") + } + tt.Key(0, ui.KeyEnter) + if !items[0].Read || last.Opened != "a" { + t.Fatalf("Enter: %+v", items[0]) + } + tt.Key(0, ui.KeyDelete) + if len(items) != 2 || items[0].ID != "b" { + t.Fatalf("Delete: %+v", items) + } + tt.Key(0, ui.KeyTab) + if tt.Focused("Unread: Invite accepted") { + t.Fatal("the list takes more than one Tab stop") + } +} + +func TestNotificationCenterPanicsOnRepeatedID(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + items := []Notification{{ID: "x"}, {ID: "x"}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NotificationCenter(c, &items, NotificationCenterOptions{}) + }, 300, 200) +} + +func TestNotificationCenterSnapshot(t *testing.T) { + items := sampleNotifications() + snapshot(t, "notification_center", 520, 400, func(c *ui.Context) { + ui.Column(c).Padding(16).Height(400).Children(func() { + NotificationCenter(c, &items, NotificationCenterOptions{}) + }) + }) +} diff --git a/spinner.go b/spinner.go index a14baac..1957ba7 100644 --- a/spinner.go +++ b/spinner.go @@ -37,25 +37,32 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element { still := c.Preferences().ReduceMotion row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center) row.Children(func() { - // MyGo's spinner carries the indeterminate progress role. - ui.Spinner(c).Key(still).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 - p.AnimationFrame() - } - cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.32 - for i := range 4 { - a := 2 * math.Pi * (turn + float64(i)/4) - x := cx + rad*float32(math.Cos(a)) - y := cy + rad*float32(math.Sin(a)) - alpha := 1 - float32(i)*0.22 - p.FillPath(internal.Diamond(x, y, r.W*0.3), color.Alpha(alpha)) - } - }) + // Keyed by motion so a flip repaints; MyGo's spinner carries the progress role. + ui.Box(c).Key(still).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) }) if opts.ShowLabel { ui.Text(c, label).TextColor(k.TextMuted).SingleLine() } }) return row } + +func spinnerMark(c *ui.Context, label string, size float32, color ui.Color, still bool) { + 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 + p.AnimationFrame() + } + cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.32 + for i := range 4 { + a := 2 * math.Pi * (turn + float64(i)/4) + x := cx + rad*float32(math.Cos(a)) + y := cy + rad*float32(math.Sin(a)) + // Device-pixel centers keep cached shapes rendering alike. + s := p.Scale() + x, y = float32(math.Round(float64(x*s)))/s, float32(math.Round(float64(y*s)))/s + alpha := 1 - float32(i)*0.22 + p.FillPath(internal.Diamond(x, y, r.W*0.3), color.Alpha(alpha)) + } + }) +} diff --git a/spinner_test.go b/spinner_test.go index 47a7abb..436ad18 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -2,6 +2,7 @@ package mujica import ( "bytes" + "slices" "testing" "time" @@ -19,8 +20,8 @@ func TestSpinnerLabelAndSize(t *testing.T) { if !ok || r.W != 16 || r.H != 16 { t.Fatalf("default mark %v %v", r, ok) } - if tt.HasText("加载中") { - t.Fatal("hidden label painted") + if n := len(slices.DeleteFunc(tt.Texts(), func(s string) bool { return s != "加载中" })); n != 1 { + t.Fatalf("hidden label painted: %d", n) } mark, ok = tt.Find("Fetching mail") if !ok || mark.W != 32 { diff --git a/testdata/snapshots/darwin/alert-dark.png b/testdata/snapshots/darwin/alert-dark.png index ff9faed..f1af51d 100644 Binary files a/testdata/snapshots/darwin/alert-dark.png 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 index c34f4df..7db185f 100644 Binary files a/testdata/snapshots/darwin/alert-light.png and b/testdata/snapshots/darwin/alert-light.png differ diff --git a/testdata/snapshots/darwin/button-dark.png b/testdata/snapshots/darwin/button-dark.png index fd0d7f4..1629bd7 100644 Binary files a/testdata/snapshots/darwin/button-dark.png and b/testdata/snapshots/darwin/button-dark.png differ diff --git a/testdata/snapshots/darwin/button-light.png b/testdata/snapshots/darwin/button-light.png index 5629ae4..30db253 100644 Binary files a/testdata/snapshots/darwin/button-light.png and b/testdata/snapshots/darwin/button-light.png differ diff --git a/testdata/snapshots/darwin/empty_state-dark.png b/testdata/snapshots/darwin/empty_state-dark.png new file mode 100644 index 0000000..44beced Binary files /dev/null and b/testdata/snapshots/darwin/empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/empty_state-light.png b/testdata/snapshots/darwin/empty_state-light.png new file mode 100644 index 0000000..debfc6b Binary files /dev/null and b/testdata/snapshots/darwin/empty_state-light.png differ diff --git a/testdata/snapshots/darwin/loading_overlay-dark.png b/testdata/snapshots/darwin/loading_overlay-dark.png new file mode 100644 index 0000000..881afd8 Binary files /dev/null and b/testdata/snapshots/darwin/loading_overlay-dark.png differ diff --git a/testdata/snapshots/darwin/loading_overlay-light.png b/testdata/snapshots/darwin/loading_overlay-light.png new file mode 100644 index 0000000..9596e41 Binary files /dev/null and b/testdata/snapshots/darwin/loading_overlay-light.png differ diff --git a/testdata/snapshots/darwin/notification_center-dark.png b/testdata/snapshots/darwin/notification_center-dark.png new file mode 100644 index 0000000..2ffba8f Binary files /dev/null and b/testdata/snapshots/darwin/notification_center-dark.png differ diff --git a/testdata/snapshots/darwin/notification_center-light.png b/testdata/snapshots/darwin/notification_center-light.png new file mode 100644 index 0000000..2c570cc Binary files /dev/null and b/testdata/snapshots/darwin/notification_center-light.png differ diff --git a/testdata/snapshots/darwin/progress-dark.png b/testdata/snapshots/darwin/progress-dark.png index 9d537d3..7d428ac 100644 Binary files a/testdata/snapshots/darwin/progress-dark.png 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 index 9d380a6..11e5976 100644 Binary files a/testdata/snapshots/darwin/progress-light.png and b/testdata/snapshots/darwin/progress-light.png differ diff --git a/testdata/snapshots/darwin/result-dark.png b/testdata/snapshots/darwin/result-dark.png new file mode 100644 index 0000000..d3a950a Binary files /dev/null and b/testdata/snapshots/darwin/result-dark.png differ diff --git a/testdata/snapshots/darwin/result-light.png b/testdata/snapshots/darwin/result-light.png new file mode 100644 index 0000000..788a99f Binary files /dev/null and b/testdata/snapshots/darwin/result-light.png differ diff --git a/testdata/snapshots/darwin/skeleton-dark.png b/testdata/snapshots/darwin/skeleton-dark.png new file mode 100644 index 0000000..f1d5134 Binary files /dev/null and b/testdata/snapshots/darwin/skeleton-dark.png differ diff --git a/testdata/snapshots/darwin/skeleton-light.png b/testdata/snapshots/darwin/skeleton-light.png new file mode 100644 index 0000000..5539f61 Binary files /dev/null and b/testdata/snapshots/darwin/skeleton-light.png differ diff --git a/testdata/snapshots/darwin/spinner-dark.png b/testdata/snapshots/darwin/spinner-dark.png new file mode 100644 index 0000000..3e65cae Binary files /dev/null and b/testdata/snapshots/darwin/spinner-dark.png differ diff --git a/testdata/snapshots/darwin/spinner-light.png b/testdata/snapshots/darwin/spinner-light.png new file mode 100644 index 0000000..fa301e6 Binary files /dev/null and b/testdata/snapshots/darwin/spinner-light.png differ diff --git a/testdata/snapshots/darwin/status_indicator-dark.png b/testdata/snapshots/darwin/status_indicator-dark.png new file mode 100644 index 0000000..3b905a2 Binary files /dev/null and b/testdata/snapshots/darwin/status_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/status_indicator-light.png b/testdata/snapshots/darwin/status_indicator-light.png new file mode 100644 index 0000000..37db59b Binary files /dev/null and b/testdata/snapshots/darwin/status_indicator-light.png differ