WIP feedback 2

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:14:31 -04:00
1 parent 7ec9ce0fed
commit aa73d174fa
25 files changed
+278 -21

No files matched your search

+119
View File
@@ -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{})
})
})
})
})
}
+3 -3
View File
@@ -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)
}
+130
View File
@@ -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{})
})
})
}
+23 -16
View File
@@ -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))
}
})
}
+3 -2
View File
@@ -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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB