Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,139 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Activity is one event of an ActivityFeed: Actor did Action to Object at Time.
|
||||
type Activity struct {
|
||||
ID string
|
||||
Actor string
|
||||
Action string
|
||||
Object string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// ActivityFeedOptions configure an ActivityFeed.
|
||||
type ActivityFeedOptions struct {
|
||||
// Location decides which day an event falls on; nil is time.Local.
|
||||
Location *time.Location
|
||||
// Today fixes the day called Today; zero uses the clock.
|
||||
Today core.Date
|
||||
}
|
||||
|
||||
// activityRow is a day header (item -1) or an event.
|
||||
type activityRow struct {
|
||||
day core.Date
|
||||
item int
|
||||
}
|
||||
|
||||
// activityFeedCache keeps the grouped rows until the count, first or last event, or location changes.
|
||||
type activityFeedCache struct {
|
||||
n int
|
||||
first, last string
|
||||
loc *time.Location
|
||||
rows []activityRow
|
||||
scroll ui.ScrollState
|
||||
sized bool
|
||||
}
|
||||
|
||||
// ActivityFeed lists events grouped by day, newest first as given, building only the rows in view.
|
||||
func ActivityFeed(c *ui.Context, items []Activity, opts ActivityFeedOptions) *ui.Element {
|
||||
loc := cmp.Or(opts.Location, time.Local)
|
||||
for _, a := range items {
|
||||
if a.ID == "" || a.Actor == "" || a.Action == "" || a.Time.IsZero() {
|
||||
panic("mujica: ActivityFeed event needs an ID, Actor, Action and Time")
|
||||
}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
sc := ui.Scroll(c).MinHeight(0).Role(ui.RoleList).Label(core.Msg(c, "activity_feed.label"))
|
||||
if len(items) == 0 {
|
||||
sc.Children(func() {
|
||||
EmptyState(c, EmptyStateOptions{Title: core.Msg(c, "activity_feed.empty"), Description: core.Msg(c, "activity_feed.empty_hint"), Icon: icons.Must("clock")})
|
||||
})
|
||||
return sc
|
||||
}
|
||||
cache := *ui.Local(sc, "activity_feed", func() *activityFeedCache { return &activityFeedCache{} })
|
||||
sc.TrackScroll(&cache.scroll)
|
||||
if cache.n != len(items) || cache.first != items[0].ID || cache.last != items[len(items)-1].ID || cache.loc != loc {
|
||||
cache.n, cache.first, cache.last, cache.loc = len(items), items[0].ID, items[len(items)-1].ID, loc
|
||||
cache.rows = cache.rows[:0]
|
||||
var prev core.Date
|
||||
for i, a := range items {
|
||||
if day := core.DateOf(a.Time.In(loc)); day != prev || i == 0 {
|
||||
cache.rows = append(cache.rows, activityRow{day: day, item: -1})
|
||||
prev = day
|
||||
}
|
||||
cache.rows = append(cache.rows, activityRow{item: i})
|
||||
}
|
||||
slog.Debug("mujica: activity_feed grouped", "events", len(items), "rows", len(cache.rows))
|
||||
}
|
||||
rowH := d.ControlHeight() + 2*d.Unit()
|
||||
view := sc.Bounds()
|
||||
if view.H == 0 && !cache.sized {
|
||||
// Unmeasured: build again once the box has its height.
|
||||
cache.sized = true
|
||||
c.Invalidate()
|
||||
}
|
||||
first := max(0, int(cache.scroll.Y/rowH)-2)
|
||||
last := min(len(cache.rows), int((cache.scroll.Y+max(view.H, rowH))/rowH)+3)
|
||||
today := core.DatetimeToday(c, opts.Today)
|
||||
sc.Children(func() {
|
||||
ui.Box(c).FillWidth().Height(rowH * float32(len(cache.rows))).Children(func() {
|
||||
for r := first; r < last; r++ {
|
||||
row := cache.rows[r]
|
||||
box := ui.Row(c).Key(r).Absolute().Left(0).Right(0).Top(rowH*float32(r)).Height(rowH).
|
||||
AlignItems(ui.Center).Gap(d.Unit()*3).Padding(0, d.Unit()*3)
|
||||
if row.item < 0 {
|
||||
activityFeedHeader(c, k, box, row.day, today)
|
||||
continue
|
||||
}
|
||||
activityFeedEvent(c, k, box, items[row.item], loc)
|
||||
}
|
||||
})
|
||||
})
|
||||
return sc
|
||||
}
|
||||
|
||||
// activityFeedHeader is a day's title: Today, Yesterday or the date, over an ornament rule.
|
||||
func activityFeedHeader(c *ui.Context, k theme.Tokens, box *ui.Element, day, today core.Date) {
|
||||
label := core.DatetimeDayLabel(c, day)
|
||||
switch day {
|
||||
case today:
|
||||
label = core.Msg(c, "activity_feed.today")
|
||||
case today.AddDays(-1):
|
||||
label = core.Msg(c, "activity_feed.yesterday")
|
||||
}
|
||||
box.Role(ui.RoleText).AlignItems(ui.End).Padding(0, core.Density(c).Unit()*3, core.Density(c).Unit(), core.Density(c).Unit()*3)
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Ornament, 0) })
|
||||
box.Children(func() {
|
||||
ui.Text(c, label).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.CaptionSize+1)).FontWeight(600).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// activityFeedEvent is one event: avatar, "Actor action Object", and the time.
|
||||
func activityFeedEvent(c *ui.Context, k theme.Tokens, box *ui.Element, a Activity, loc *time.Location) {
|
||||
when := a.Time.In(loc).Format("15:04")
|
||||
box.Role(ui.RoleListItem).Label(a.Actor + " " + a.Action + " " + a.Object + ", " + when)
|
||||
box.Children(func() {
|
||||
display.Avatar(c, a.Actor, display.AvatarOptions{Size: 28})
|
||||
ui.Row(c).Grow(1).Shrink(1).MinWidth(0).Gap(4).Children(func() {
|
||||
ui.Text(c, a.Actor).Bold().TextColor(k.Text).SingleLine().Shrink(0)
|
||||
ui.Text(c, a.Action).TextColor(k.TextMuted).SingleLine().Shrink(0)
|
||||
if a.Object != "" {
|
||||
ui.Text(c, a.Object).TextColor(k.AccentText).SingleLine().Shrink(1).Ellipsis("…")
|
||||
}
|
||||
})
|
||||
ui.Text(c, when).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func feedItems(n int) []Activity {
|
||||
base := time.Date(2026, 10, 5, 18, 0, 0, 0, time.UTC)
|
||||
out := make([]Activity, n)
|
||||
for i := range out {
|
||||
out[i] = Activity{ID: strconv.Itoa(i), Actor: "Mira", Action: "edited", Object: "Doc " + strconv.Itoa(i), Time: base.Add(-time.Duration(i) * 5 * time.Hour)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestActivityFeedGroupsByDay(t *testing.T) {
|
||||
items := feedItems(6) // 18:00, 13:00, 08:00, 03:00 on the 5th, then 22:00 and 17:00 on the 4th
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: core.Date{Year: 2026, Month: 10, Day: 5}}).Size(600, 500)
|
||||
}, 640, 540)
|
||||
tt.Frame()
|
||||
for _, s := range []string{"Today", "Yesterday", "Mira edited Doc 0, 18:00", "Mira edited Doc 4, 22:00"} {
|
||||
if _, ok := tt.Find(s); !ok {
|
||||
t.Errorf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
today, _ := tt.Find("Today")
|
||||
yest, _ := tt.Find("Yesterday")
|
||||
doc3, _ := tt.Find("Mira edited Doc 3, 03:00")
|
||||
doc4, _ := tt.Find("Mira edited Doc 4, 22:00")
|
||||
if !(today.Y < doc3.Y && doc3.Y < yest.Y && yest.Y < doc4.Y) {
|
||||
t.Fatalf("order: today %v, doc3 %v, yesterday %v, doc4 %v", today, doc3, yest, doc4)
|
||||
}
|
||||
// Another zone moves 03:00 UTC to the previous day.
|
||||
tt2 := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
ActivityFeed(c, items, ActivityFeedOptions{Location: time.FixedZone("W", -5*3600), Today: core.Date{Year: 2026, Month: 10, Day: 5}}).Size(600, 500)
|
||||
}, 640, 540)
|
||||
tt2.Frame()
|
||||
if _, ok := tt2.Find("Mira edited Doc 3, 22:00"); !ok || !tt2.HasText("今天") || !tt2.HasText("昨天") {
|
||||
t.Fatalf("zone texts %q", tt2.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityFeedVirtualizesAndEmpty(t *testing.T) {
|
||||
items := feedItems(100000)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: core.Date{Year: 2026, Month: 10, Day: 5}}).Size(600, 400)
|
||||
}, 640, 440)
|
||||
tt.Frame()
|
||||
if n := len(tt.Texts()); n == 0 || n > 120 {
|
||||
t.Fatalf("built %d texts for 100000 events", n)
|
||||
}
|
||||
tt.Scroll(100, 100, 0, 2000000)
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find("Mira edited Doc 0, 18:00"); ok {
|
||||
t.Fatal("first event still built after scrolling far")
|
||||
}
|
||||
tt3 := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ActivityFeed(c, nil, ActivityFeedOptions{}).Size(400, 300)
|
||||
}, 440, 340)
|
||||
if !tt3.HasText("No activity yet") {
|
||||
t.Fatalf("empty: %q", tt3.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("event without ID accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ActivityFeed(c, []Activity{{Actor: "x"}}, ActivityFeedOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestActivityFeedSnapshot(t *testing.T) {
|
||||
items := feedItems(5)
|
||||
items[1].Actor, items[1].Action, items[1].Object = "Lin", "commented on", "Quarterly report"
|
||||
items[3].Actor, items[3].Action, items[3].Object = "Sakiko", "approved", "Merge request !42"
|
||||
snapshot(t, "activity_feed", 520, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
ActivityFeed(c, items, ActivityFeedOptions{Location: time.UTC, Today: core.Date{Year: 2026, Month: 10, Day: 5}}).Size(496, 296)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AlertOptions configure an Alert.
|
||||
type AlertOptions struct {
|
||||
Severity core.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
|
||||
}
|
||||
|
||||
// Alert shows an inline message; a non-nil open adds a dismiss button that clears it.
|
||||
func Alert(c *ui.Context, open *bool, message string, opts AlertOptions) core.AlertResult {
|
||||
if message == "" {
|
||||
panic("mujica: Alert needs a message")
|
||||
}
|
||||
if open != nil && !*open {
|
||||
return core.AlertResult{}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
tone, icon := core.SeverityLook(k, opts.Severity)
|
||||
name := core.Msg(c, "alert."+opts.Severity.String())
|
||||
var res core.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 >= core.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 input.Button(c, opts.Action, input.ButtonOptions{Variant: input.Outline}).Clicked() {
|
||||
slog.Debug("mujica: alert action", "severity", opts.Severity, "action", opts.Action)
|
||||
res.Action = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if open != nil {
|
||||
dismiss := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("x"), Label: core.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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestAlertActionAndClose(t *testing.T) {
|
||||
open := true
|
||||
actions, closes := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := Alert(c, &open, "Disk almost full", AlertOptions{Severity: core.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if Alert(c, &open, "Sync failed", AlertOptions{Severity: core.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) {
|
||||
core.Use(c, core.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 := core.SeverityInfo
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
Alert(c, nil, "磁盘已满", AlertOptions{Severity: sev})
|
||||
}, 500, 300)
|
||||
if a := tt.Announcements(); len(a) != 0 {
|
||||
t.Fatalf("info announced %q", a)
|
||||
}
|
||||
sev = core.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) {
|
||||
core.Use(c, core.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: core.SeveritySuccess})
|
||||
Alert(c, nil, "The certificate expires in 3 days.", AlertOptions{Severity: core.SeverityWarning, Title: "Certificate"})
|
||||
Alert(c, &open, "The archive could not be read.", AlertOptions{Severity: core.SeverityError, Title: "Import failed", Action: "Retry"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// BarsLoader shows four bars rising and falling in turn.
|
||||
func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, "bars_loader", 1.25, 1000, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
w := r.W / 7
|
||||
for i := range 4 {
|
||||
h := float32(0.6)
|
||||
if !still {
|
||||
h = 0.3 + 0.7*loaderWave(t-float64(i)/8)
|
||||
}
|
||||
bh := internal.Snap(p, r.H*h)
|
||||
x := r.X + float32(i)*w*2
|
||||
p.Fill(ui.Rect{X: x, Y: internal.Snap(p, r.Y+(r.H-bh)/2), W: w, H: bh}, col, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBarsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "bars_loader", BarsLoader) }
|
||||
|
||||
func TestBarsLoaderBehavior(t *testing.T) { testLoaderBehavior(t, BarsLoader) }
|
||||
@@ -0,0 +1,84 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BlinkHighlightOptions configure a BlinkHighlight.
|
||||
type BlinkHighlightOptions struct {
|
||||
// Format writes the value; nil uses two decimals.
|
||||
Format func(v float64) string
|
||||
// Duration of the fade; zero means StateDuration.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type blinkState struct {
|
||||
value float64
|
||||
dir int
|
||||
at time.Time
|
||||
set bool
|
||||
}
|
||||
|
||||
// BlinkHighlight tints value once on change and marks its direction.
|
||||
func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *ui.Element {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || opts.Duration < 0 {
|
||||
panic("mujica: BlinkHighlight needs a finite value and a non-negative Duration")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.StateDuration
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(0, u*1.5).Radius(theme.ControlRadius).Shrink(0)
|
||||
st := ui.Local(row, "blink", func() blinkState { return blinkState{} })
|
||||
now := c.Now()
|
||||
if st.set && value != st.value {
|
||||
st.dir = 1
|
||||
if value < st.value {
|
||||
st.dir = -1
|
||||
}
|
||||
st.at = now
|
||||
slog.Debug("mujica: blink highlight", "from", st.value, "to", value)
|
||||
}
|
||||
st.value, st.set = value, true
|
||||
if c.Preferences().ReduceMotion {
|
||||
st.at = time.Time{}
|
||||
}
|
||||
col := k.Success
|
||||
icon, word := "arrow-up", "blink_highlight.up"
|
||||
if st.dir < 0 {
|
||||
col, icon, word = k.Danger, "arrow-down", "blink_highlight.down"
|
||||
}
|
||||
name := format(value)
|
||||
if st.dir != 0 {
|
||||
name += ", " + core.Msg(c, word)
|
||||
}
|
||||
row.Label(name)
|
||||
if dur := core.Motion(c, d); st.dir != 0 && !st.at.IsZero() && dur > 0 {
|
||||
if p := float32(now.Sub(st.at)) / float32(dur); p < 1 {
|
||||
row.Background(col.Alpha(0.22 * (1 - p)))
|
||||
row.DrawOver(func(*ui.Painter, ui.Rect) { c.AnimationFrame() })
|
||||
}
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Text(c, format(value)).FontFeatures("tnum").SingleLine()
|
||||
if st.dir != 0 {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(col)
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestBlinkHighlightFlashesOnceWithDirection(t *testing.T) {
|
||||
value := 10.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
BlinkHighlight(c, value, BlinkHighlightOptions{Duration: 200 * time.Millisecond})
|
||||
}, 200, 60)
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("first value flashed")
|
||||
}
|
||||
if _, ok := tt.Find("10.00"); !ok {
|
||||
t.Fatal("value not named")
|
||||
}
|
||||
value = 12
|
||||
tt.Frame()
|
||||
a := encode(t, tt.Image())
|
||||
if _, ok := tt.Find("12.00, up"); !ok {
|
||||
t.Fatalf("direction not named: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || motionBusy(tt, n) {
|
||||
t.Fatal("flash did not fade and stop")
|
||||
}
|
||||
value = 9
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find("9.00, down"); !ok {
|
||||
t.Fatalf("down not named: %q", tt.Texts())
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
value = 11
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("flash under reduced motion")
|
||||
}
|
||||
if _, ok := tt.Find("11.00, up"); !ok {
|
||||
t.Fatal("static mark missing under reduced motion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlinkHighlightInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BlinkHighlight(c, 1, BlinkHighlightOptions{Duration: -1})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestBlinkHighlightSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "blink_highlight", 300, 100, func(c *ui.Context) {
|
||||
v := ui.Local(c.Root(), "v", func() float64 { return 101.5 })
|
||||
w := ui.Local(c.Root(), "w", func() float64 { return 88.25 })
|
||||
ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
BlinkHighlight(c, *v, BlinkHighlightOptions{})
|
||||
BlinkHighlight(c, *w, BlinkHighlightOptions{})
|
||||
})
|
||||
*v, *w = 103.75, 86.5
|
||||
}, func(tt *ui.Tester) { tt.Frame() })
|
||||
}
|
||||
|
||||
func TestBlinkHighlightRestoredMotionKeepsStaticMark(t *testing.T) {
|
||||
value := 1.0
|
||||
tt, n := motionFrames(func(c *ui.Context) { BlinkHighlight(c, value, BlinkHighlightOptions{Duration: 5 * time.Second}) }, 300, 100)
|
||||
value = 2
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("old highlight replayed after restoring motion")
|
||||
}
|
||||
if _, ok := tt.Find("2.00, up"); !ok {
|
||||
t.Fatal("direction mark lost")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CostItem is the usage and cost of one call.
|
||||
type CostItem struct {
|
||||
Model, Tool, Step string
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
Cost float64
|
||||
}
|
||||
|
||||
// CostGroup chooses what a CostBreakdown sums by.
|
||||
type CostGroup int
|
||||
|
||||
const (
|
||||
CostByModel CostGroup = iota
|
||||
CostByTool
|
||||
CostByStep
|
||||
)
|
||||
|
||||
// CostBreakdownOptions configure a CostBreakdown.
|
||||
type CostBreakdownOptions struct {
|
||||
GroupBy CostGroup
|
||||
// Version changes when items are edited in place.
|
||||
Version int
|
||||
// Currency precedes amounts; empty means "$".
|
||||
Currency string
|
||||
// Decimals of amounts; zero means 2.
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// CostBreakdownState holds the table's sort and selection and the sums.
|
||||
type CostBreakdownState struct {
|
||||
Table data.DataTableState[string]
|
||||
rows []costRow
|
||||
total costRow
|
||||
src *CostItem
|
||||
n int
|
||||
group CostGroup
|
||||
built bool
|
||||
version int
|
||||
unnamed string
|
||||
}
|
||||
|
||||
type costRow struct {
|
||||
name string
|
||||
calls int
|
||||
input, output int
|
||||
cost float64
|
||||
}
|
||||
|
||||
// CostBreakdown totals grouped costs; give the element a height.
|
||||
func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts CostBreakdownOptions) data.DataTableView {
|
||||
if opts.GroupBy < CostByModel || opts.GroupBy > CostByStep || opts.Decimals < 0 {
|
||||
panic("mujica: CostBreakdown needs a known GroupBy and non-negative Decimals")
|
||||
}
|
||||
cur, dec := opts.Currency, opts.Decimals
|
||||
if cur == "" {
|
||||
cur = "$"
|
||||
}
|
||||
if dec == 0 {
|
||||
dec = 2
|
||||
}
|
||||
var first *CostItem
|
||||
if len(items) > 0 {
|
||||
first = &items[0]
|
||||
}
|
||||
unnamed := core.Msg(c, "cost_breakdown.unnamed")
|
||||
if !s.built || s.src != first || s.n != len(items) || s.group != opts.GroupBy || s.version != opts.Version || s.unnamed != unnamed {
|
||||
s.rows, s.total = costSum(items, opts.GroupBy, unnamed)
|
||||
s.src, s.n, s.group, s.built = first, len(items), opts.GroupBy, true
|
||||
s.version, s.unnamed = opts.Version, unnamed
|
||||
slog.Debug("mujica: cost breakdown sum", "items", len(items), "groups", len(s.rows))
|
||||
}
|
||||
money := func(v float64) string { return cur + strconv.FormatFloat(v, 'f', dec, 64) }
|
||||
count := func(n int) string { return strconv.Itoa(n) }
|
||||
share := func(r costRow) float64 {
|
||||
if s.total.cost == 0 {
|
||||
return 0
|
||||
}
|
||||
return r.cost / s.total.cost
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
groupTitle := core.Msg(c, []string{"cost_breakdown.model", "cost_breakdown.tool", "cost_breakdown.step"}[opts.GroupBy])
|
||||
cols := []data.DataColumn[costRow]{
|
||||
{ID: "name", Title: groupTitle, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.name, b.name) }, Text: func(r costRow) string { return r.name }},
|
||||
{ID: "calls", Title: core.Msg(c, "cost_breakdown.calls"), Width: 80, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.calls, b.calls) }, Text: func(r costRow) string { return count(r.calls) }},
|
||||
{ID: "input", Title: core.Msg(c, "cost_breakdown.input"), Width: 120, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.input, b.input) }, Text: func(r costRow) string { return count(r.input) }},
|
||||
{ID: "output", Title: core.Msg(c, "cost_breakdown.output"), Width: 130, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.output, b.output) }, Text: func(r costRow) string { return count(r.output) }},
|
||||
{ID: "cost", Title: core.Msg(c, "cost_breakdown.cost"), Width: 160, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.cost, b.cost) },
|
||||
Text: func(r costRow) string { return money(r.cost) },
|
||||
Cell: func(r costRow) {
|
||||
ui.Row(c).Grow(1).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
f := share(r)
|
||||
ui.Box(c).Height(6).Grow(1).Radius(1).Background(k.Border).Role(ui.RoleNone).Draw(func(p *ui.Painter, b ui.Rect) {
|
||||
p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * float32(f), H: b.H}, k.AccentText, 1)
|
||||
})
|
||||
ui.Text(c, money(r.cost)).FontFeatures("tnum").Shrink(0)
|
||||
ui.Text(c, fmt.Sprintf("%.0f%%", 100*f)).FontFeatures("tnum").TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Width(36).TextAlign(ui.End).Shrink(0)
|
||||
})
|
||||
}},
|
||||
}
|
||||
var v data.DataTableView
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
col.Children(func() {
|
||||
v = data.DataTable(c, &s.Table, s.rows, data.DataTableOptions[costRow, string]{Columns: cols, Key: func(r costRow) string { return r.name }, Empty: core.Msg(c, "cost_breakdown.empty")})
|
||||
v.Element.Grow(1)
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*4).Padding(u*2, u*3).Background(k.Background).Radius(theme.ControlRadius).Role(ui.RoleStatus).
|
||||
Label(fmt.Sprintf(core.Msg(c, "cost_breakdown.total_name"), money(s.total.cost), s.total.calls)).Children(func() {
|
||||
ui.Text(c, core.Msg(c, "cost_breakdown.total")).Font(theme.SerifFont).FontWeight(600).Grow(1)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "cost_breakdown.tokens"), s.total.input, s.total.output)).FontFeatures("tnum").TextColor(k.TextMuted)
|
||||
ui.Text(c, money(s.total.cost)).FontWeight(700).FontFeatures("tnum")
|
||||
})
|
||||
})
|
||||
v.Element = col
|
||||
return v
|
||||
}
|
||||
|
||||
// costSum totals items per group, in order of first appearance.
|
||||
func costSum(items []CostItem, by CostGroup, unnamed string) ([]costRow, costRow) {
|
||||
var rows []costRow
|
||||
at := map[string]int{}
|
||||
var total costRow
|
||||
for _, it := range items {
|
||||
if it.InputTokens < 0 || it.OutputTokens < 0 || it.Cost < 0 || math.IsNaN(it.Cost) || math.IsInf(it.Cost, 0) {
|
||||
panic("mujica: CostItem needs non-negative, finite counts and cost")
|
||||
}
|
||||
name := []string{it.Model, it.Tool, it.Step}[by]
|
||||
if name == "" {
|
||||
name = unnamed
|
||||
}
|
||||
i, ok := at[name]
|
||||
if !ok {
|
||||
i = len(rows)
|
||||
at[name] = i
|
||||
rows = append(rows, costRow{name: name})
|
||||
}
|
||||
for _, r := range []*costRow{&rows[i], &total} {
|
||||
if r.input > math.MaxInt-it.InputTokens || r.output > math.MaxInt-it.OutputTokens || math.IsInf(r.cost+it.Cost, 0) {
|
||||
panic("mujica: CostBreakdown total exceeds numeric range")
|
||||
}
|
||||
r.calls++
|
||||
r.input += it.InputTokens
|
||||
r.output += it.OutputTokens
|
||||
r.cost += it.Cost
|
||||
}
|
||||
}
|
||||
return rows, total
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func costSample() []CostItem {
|
||||
return []CostItem{
|
||||
{Model: "opus", Tool: "search", Step: "plan", InputTokens: 1000, OutputTokens: 200, Cost: 0.30},
|
||||
{Model: "haiku", Tool: "search", Step: "gather", InputTokens: 4000, OutputTokens: 500, Cost: 0.05},
|
||||
{Model: "opus", Tool: "write", Step: "write", InputTokens: 2000, OutputTokens: 3000, Cost: 1.15},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostSum(t *testing.T) {
|
||||
rows, total := costSum(costSample(), CostByModel, "?")
|
||||
if len(rows) != 2 || rows[0].name != "opus" || rows[0].calls != 2 || rows[0].input != 3000 || total.calls != 3 {
|
||||
t.Fatalf("rows %+v total %+v", rows, total)
|
||||
}
|
||||
if d := total.cost - 1.5; d > 1e-9 || d < -1e-9 {
|
||||
t.Fatalf("total cost %v", total.cost)
|
||||
}
|
||||
rows, _ = costSum([]CostItem{{Cost: 1}}, CostByTool, "(none)")
|
||||
if rows[0].name != "(none)" {
|
||||
t.Fatalf("unnamed group %q", rows[0].name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownGroupsAndTotal(t *testing.T) {
|
||||
var s CostBreakdownState
|
||||
by := CostByModel
|
||||
items := costSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
CostBreakdown(c, &s, items, CostBreakdownOptions{GroupBy: by}).Element.Grow(1)
|
||||
})
|
||||
}, 800, 420)
|
||||
for _, want := range []string{"opus", "haiku", "$1.45", "$0.05", "97%", "Total", "$1.50", "7000 in · 3700 out"} {
|
||||
if !tt.HasText(want) {
|
||||
t.Errorf("missing %q in %q", want, tt.Texts())
|
||||
}
|
||||
}
|
||||
if _, ok := tt.Find("Total $1.50 over 3 calls"); !ok {
|
||||
t.Error("total not named")
|
||||
}
|
||||
rows := &s.rows[0]
|
||||
tt.Frame()
|
||||
if &s.rows[0] != rows {
|
||||
t.Fatal("sums recomputed without a change")
|
||||
}
|
||||
by = CostByStep
|
||||
tt.Frame()
|
||||
if !tt.HasText("gather") || tt.HasText("haiku") {
|
||||
t.Fatalf("group by step: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Cost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Table.Sort.Column != "cost" {
|
||||
t.Fatalf("sort %+v", s.Table.Sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownInvalid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
items []CostItem
|
||||
opts CostBreakdownOptions
|
||||
}{{[]CostItem{{Cost: -1}}, CostBreakdownOptions{}}, {nil, CostBreakdownOptions{GroupBy: 7}}, {nil, CostBreakdownOptions{Decimals: -1}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", tc)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s CostBreakdownState
|
||||
CostBreakdown(c, &s, tc.items, tc.opts)
|
||||
}, 300, 200, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownSnapshot(t *testing.T) {
|
||||
items := costSample()
|
||||
snapshot(t, "cost_breakdown", 760, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *CostBreakdownState { return &CostBreakdownState{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
CostBreakdown(c, *s, items, CostBreakdownOptions{}).Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCostBreakdownVersionAndToolGroups(t *testing.T) {
|
||||
var s CostBreakdownState
|
||||
items := costSample()
|
||||
version := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CostBreakdown(c, &s, items, CostBreakdownOptions{GroupBy: CostByTool, Version: version}).Element.Height(300)
|
||||
}, 800, 400)
|
||||
if len(s.rows) != 2 || s.rows[0].name != "search" || s.rows[0].calls != 2 {
|
||||
t.Fatalf("tool groups %+v", s.rows)
|
||||
}
|
||||
items[0].Cost = 1.30
|
||||
version++
|
||||
tt.Frame()
|
||||
if !tt.HasText("$2.50") {
|
||||
t.Fatalf("stale total: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostBreakdownLargeData(t *testing.T) {
|
||||
items := make([]CostItem, 100000)
|
||||
for i := range items {
|
||||
items[i] = CostItem{Model: strconv.Itoa(i), Cost: 1}
|
||||
}
|
||||
var s CostBreakdownState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CostBreakdown(c, &s, items, CostBreakdownOptions{}).Element.Height(300)
|
||||
}, 800, 400)
|
||||
if s.total.cost != 100000 || s.Table.Rows() != 100000 || len(tt.Texts()) > 300 {
|
||||
t.Fatalf("rows %d texts %d total %v", s.Table.Rows(), len(tt.Texts()), s.total.cost)
|
||||
}
|
||||
a, b := s.Table.VisibleRows()
|
||||
if b-a > 30 {
|
||||
t.Fatalf("built %d rows", b-a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostSumOverflowPanics(t *testing.T) {
|
||||
for _, items := range [][]CostItem{
|
||||
{{InputTokens: math.MaxInt}, {InputTokens: 1}},
|
||||
{{OutputTokens: math.MaxInt}, {OutputTokens: 1}},
|
||||
{{Cost: math.MaxFloat64}, {Cost: math.MaxFloat64}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("unrepresentable cost total accepted")
|
||||
}
|
||||
}()
|
||||
costSum(items, CostByModel, "unnamed")
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CountUpOptions configure a CountUp.
|
||||
type CountUpOptions struct {
|
||||
// Duration of a change; zero means StateDuration.
|
||||
Duration time.Duration
|
||||
// Decimals shown; Format overrides it.
|
||||
Decimals int
|
||||
// Format writes the shown value; nil uses Decimals.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
type countUpState struct {
|
||||
from, to float64
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// CountUp counts from the last value to the new one.
|
||||
func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
panic("mujica: CountUp value must be finite")
|
||||
}
|
||||
if opts.Duration < 0 || opts.Decimals < 0 {
|
||||
panic("mujica: CountUp Duration and Decimals must not be negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.StateDuration
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', opts.Decimals, 64) }
|
||||
}
|
||||
dur := core.Motion(c, d)
|
||||
now := c.Now()
|
||||
row := ui.Row(c).Shrink(0).Label(format(value))
|
||||
st := ui.Local(row, "count", func() countUpState { return countUpState{from: value, to: value} })
|
||||
at := func() float64 {
|
||||
if dur == 0 {
|
||||
return st.to
|
||||
}
|
||||
p := min(1, float64(now.Sub(st.start))/float64(dur))
|
||||
eased := float64(ui.EaseOut(float32(p)))
|
||||
return st.from*(1-eased) + st.to*eased
|
||||
}
|
||||
if value != st.to {
|
||||
from := at()
|
||||
slog.Debug("mujica: count up", "from", from, "to", value)
|
||||
st.from, st.to, st.start = from, value, now
|
||||
}
|
||||
if dur == 0 {
|
||||
st.from, st.start = st.to, now
|
||||
}
|
||||
shown := at()
|
||||
if shown != st.to {
|
||||
row.DrawOver(func(*ui.Painter, ui.Rect) { c.AnimationFrame() })
|
||||
}
|
||||
return row.Children(func() { ui.Text(c, format(shown)).FontFeatures("tnum").SingleLine() })
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCountUpCountsAndSettles(t *testing.T) {
|
||||
value := 100.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
CountUp(c, value, CountUpOptions{Duration: 300 * time.Millisecond})
|
||||
}, 200, 60)
|
||||
if !tt.HasText("100") || motionBusy(tt, n) {
|
||||
t.Fatalf("first value shown at once: %q", tt.Texts())
|
||||
}
|
||||
value = 200
|
||||
tt.Frame()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
mid := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if s != "200" {
|
||||
mid = s
|
||||
}
|
||||
}
|
||||
if mid == "" || mid == "100" {
|
||||
t.Fatalf("no value between: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionCount(tt, "200") != 2 || motionBusy(tt, n) {
|
||||
t.Fatalf("did not settle: %q", tt.Texts())
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
value = 5
|
||||
tt.Frame()
|
||||
if motionCount(tt, "5") != 2 {
|
||||
t.Fatalf("reduced motion did not jump: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountUpFormatAndInvalid(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CountUp(c, 12.345, CountUpOptions{Decimals: 1})
|
||||
CountUp(c, 3, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.0f items", v) }})
|
||||
}, 300, 80)
|
||||
if !tt.HasText("12.3") || !tt.HasText("3 items") {
|
||||
t.Fatalf("format: %q", tt.Texts())
|
||||
}
|
||||
for _, o := range []CountUpOptions{{Duration: -1}, {Decimals: -1}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", o)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); CountUp(c, 1, o) }, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "finite") {
|
||||
t.Fatal("NaN did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); CountUp(c, nanValue(), CountUpOptions{}) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func nanValue() float64 {
|
||||
zero := 0.0
|
||||
return zero / zero
|
||||
}
|
||||
|
||||
func TestCountUpSnapshot(t *testing.T) {
|
||||
snapshot(t, "count_up", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
CountUp(c, 12840, CountUpOptions{}).FontSize(core.FontSize(c, 28)).Font("Georgia, serif")
|
||||
CountUp(c, 0.875, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCountUpFiniteExtremeInterpolation(t *testing.T) {
|
||||
value := -math.MaxFloat64
|
||||
tt, _ := motionFrames(func(c *ui.Context) {
|
||||
CountUp(c, value, CountUpOptions{Duration: time.Second, Format: func(v float64) string {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
t.Fatal("finite endpoints produced non-finite text")
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
}})
|
||||
}, 300, 80)
|
||||
value = math.MaxFloat64
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestCountUpOffscreenStopsFrames(t *testing.T) {
|
||||
value := 1.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Height(80).Clip().Children(func() {
|
||||
ui.Box(c).Size(10, 200).Shrink(0)
|
||||
CountUp(c, value, CountUpOptions{Duration: time.Second})
|
||||
})
|
||||
}, 300, 80)
|
||||
value = 2
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("offscreen CountUp requested continuous rebuilds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountUpRestoredMotionKeepsFinalValue(t *testing.T) {
|
||||
value := 0.0
|
||||
shown := 0.0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
CountUp(c, value, CountUpOptions{Duration: 5 * time.Second, Format: func(v float64) string { shown = v; return fmt.Sprintf("%g", v) }})
|
||||
}, 300, 100)
|
||||
value = 100
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
if shown != 100 || motionBusy(tt, n) {
|
||||
t.Fatalf("restored value %v", shown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package feedback holds MujicaUI alerts, toasts, progress, empty states and agent ops loaders and motion.
|
||||
package feedback
|
||||
@@ -0,0 +1,81 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LoaderOptions configure the loaders: DotsLoader, BarsLoader, PulseLoader, OrbitLoader and WaveLoader.
|
||||
type LoaderOptions struct {
|
||||
// Size is the mark's height in DIPs; zero means 16.
|
||||
Size float32
|
||||
// Label is read out; empty means "Loading".
|
||||
Label string
|
||||
ShowLabel bool
|
||||
// Color paints the mark; zero uses AccentText.
|
||||
Color ui.Color
|
||||
}
|
||||
|
||||
// loaderDraw paints phase t; still enables reduced motion.
|
||||
type loaderDraw func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool)
|
||||
|
||||
// loaderMark builds a loader that repaints only while painted.
|
||||
func loaderMark(c *ui.Context, opts LoaderOptions, key string, aspect float32, period int64, draw loaderDraw) *ui.Element {
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic("mujica: loader Size must be finite and not negative")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
size := opts.Size
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, key+".loading")
|
||||
}
|
||||
col := opts.Color
|
||||
if col.A == 0 {
|
||||
col = k.AccentText
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
row := ui.Row(c).Gap(core.Density(c).Unit() * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleProgress).Label(label)
|
||||
row.Children(func() {
|
||||
ui.Box(c).Key(still).Size(size*aspect, size).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
t := 0.0
|
||||
if !still {
|
||||
t = float64(p.Now().UnixMilli()%period) / float64(period)
|
||||
p.AnimationFrame()
|
||||
}
|
||||
draw(p, r, t, col, still)
|
||||
})
|
||||
// Reduced motion shows the words, as the mark stands still.
|
||||
if opts.ShowLabel || still {
|
||||
ui.Text(c, label).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// loaderWave rises and falls between 0 and 1.
|
||||
func loaderWave(t float64) float32 { return float32(0.5 - 0.5*math.Cos(2*math.Pi*t)) }
|
||||
|
||||
// DotsLoader shows three dots lighting up in turn.
|
||||
func DotsLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, "dots_loader", 2.5, 1200, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
d := r.H * 0.36
|
||||
for i := range 3 {
|
||||
a := float32(1)
|
||||
if !still {
|
||||
a = 0.3 + 0.7*loaderWave(t-float64(i)/6)
|
||||
}
|
||||
var dot ui.Path
|
||||
cx := r.X + r.W*(float32(i)+0.5)/3
|
||||
dot.Circle(cx, r.Y+r.H/2, d/2)
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDotsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "dots_loader", DotsLoader) }
|
||||
|
||||
func TestDotsLoaderBehavior(t *testing.T) { testLoaderBehavior(t, DotsLoader) }
|
||||
@@ -0,0 +1,90 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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, core.Msg(c, "empty_state.no_data.title"))
|
||||
desc = cmp.Or(desc, core.Msg(c, "empty_state.no_data.description"))
|
||||
if icon == nil {
|
||||
icon = icons.Must("inbox")
|
||||
}
|
||||
case EmptyNoResults:
|
||||
title = cmp.Or(title, core.Msg(c, "empty_state.no_results.title"))
|
||||
if desc == "" && opts.Query != "" {
|
||||
desc = fmt.Sprintf(core.Msg(c, "empty_state.no_results.query"), opts.Query)
|
||||
}
|
||||
desc = cmp.Or(desc, core.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(core.FontSize(c, 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 = input.Button(c, opts.Action, input.ButtonOptions{Variant: input.Secondary}).Clicked()
|
||||
})
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
func TestEmptyStateKinds(t *testing.T) {
|
||||
opts := EmptyStateOptions{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Locale: core.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) { core.Use(c, core.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,137 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
)
|
||||
|
||||
// EvalCase is one evaluation case and its outcome.
|
||||
type EvalCase struct {
|
||||
ID string
|
||||
Name string
|
||||
Score float64
|
||||
Passed bool
|
||||
}
|
||||
|
||||
// EvalResultTableOptions configure an EvalResultTable.
|
||||
type EvalResultTableOptions struct {
|
||||
// Decimals of scores; zero means 2.
|
||||
Decimals int
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
// Version changes when cases are edited in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// EvalResultTableResult is a built EvalResultTable and its events.
|
||||
type EvalResultTableResult struct {
|
||||
Element *ui.Element
|
||||
Table data.DataTableView
|
||||
diff string
|
||||
}
|
||||
|
||||
// Diff returns the case to show differences for.
|
||||
func (r EvalResultTableResult) Diff() (string, bool) { return r.diff, r.diff != "" }
|
||||
|
||||
type evalSummary struct {
|
||||
src *EvalCase
|
||||
n, version, passed int
|
||||
built bool
|
||||
}
|
||||
|
||||
// EvalResultTable lists scored cases with a pass summary.
|
||||
func EvalResultTable(c *ui.Context, s *data.DataTableState[string], cases []EvalCase, opts EvalResultTableOptions) EvalResultTableResult {
|
||||
if opts.Decimals < 0 {
|
||||
panic("mujica: EvalResultTable Decimals is negative")
|
||||
}
|
||||
dec := opts.Decimals
|
||||
if dec == 0 {
|
||||
dec = 2
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
res := EvalResultTableResult{Element: col}
|
||||
summary := ui.Local(col, "summary", func() evalSummary { return evalSummary{} })
|
||||
var first *EvalCase
|
||||
if len(cases) > 0 {
|
||||
first = &cases[0]
|
||||
}
|
||||
if !summary.built || summary.src != first || summary.n != len(cases) || summary.version != opts.Version {
|
||||
passed := 0
|
||||
seen := make(map[string]bool, len(cases))
|
||||
for _, ec := range cases {
|
||||
if ec.ID == "" || seen[ec.ID] || math.IsNaN(ec.Score) || math.IsInf(ec.Score, 0) {
|
||||
panic("mujica: EvalCase needs a unique ID and finite Score")
|
||||
}
|
||||
seen[ec.ID] = true
|
||||
if ec.Passed {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
*summary = evalSummary{src: first, n: len(cases), version: opts.Version, passed: passed, built: true}
|
||||
}
|
||||
passed := summary.passed
|
||||
verdict := func(ec EvalCase) string {
|
||||
if ec.Passed {
|
||||
return core.Msg(c, "eval_result_table.pass")
|
||||
}
|
||||
return core.Msg(c, "eval_result_table.fail")
|
||||
}
|
||||
cols := []data.DataColumn[EvalCase]{
|
||||
{ID: "name", Title: core.Msg(c, "eval_result_table.case"), Sortable: true, Compare: func(a, b EvalCase) int { return cmp.Compare(a.Name, b.Name) }, Text: func(e EvalCase) string { return e.Name }},
|
||||
{ID: "score", Title: core.Msg(c, "eval_result_table.score"), Width: 90, Align: ui.End, Sortable: true, Compare: func(a, b EvalCase) int { return cmp.Compare(a.Score, b.Score) },
|
||||
Text: func(e EvalCase) string { return strconv.FormatFloat(e.Score, 'f', dec, 64) }},
|
||||
{ID: "result", Title: core.Msg(c, "eval_result_table.result"), Width: 110, Sortable: true,
|
||||
Compare: func(a, b EvalCase) int { return cmp.Compare(evalRank(a), evalRank(b)) }, Text: verdict,
|
||||
Cell: func(e EvalCase) {
|
||||
icon, col := "circle-check", k.Success
|
||||
if !e.Passed {
|
||||
icon, col = "circle-x", k.Danger
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, verdict(e)).FontWeight(600)
|
||||
})
|
||||
}},
|
||||
{ID: "diff", Title: "", Width: 48, Text: func(EvalCase) string { return "" },
|
||||
Cell: func(e EvalCase) {
|
||||
b := core.IconAction(c, icons.Must("arrow-left-right"), fmt.Sprintf(core.Msg(c, "eval_result_table.diff"), e.Name)).Tooltip(core.Msg(c, "eval_result_table.view"))
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
res.diff = e.ID
|
||||
slog.Debug("mujica: eval result diff", "case", e.ID)
|
||||
}
|
||||
}},
|
||||
}
|
||||
col.Children(func() {
|
||||
if len(cases) > 0 {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "eval_result_table.summary"), passed, len(cases))).FontWeight(600)
|
||||
ui.Box(c).Height(6).Width(160).Radius(1).Background(k.Danger.Mix(k.Surface, 0.6)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(passed) / float32(len(cases)), H: r.H}, k.Success, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
res.Table = data.DataTable(c, s, cases, data.DataTableOptions[EvalCase, string]{Columns: cols, Key: func(e EvalCase) string { return e.ID },
|
||||
Empty: core.Msg(c, "eval_result_table.empty"), Status: opts.Status, Error: opts.Error, Version: opts.Version})
|
||||
res.Table.Element.Grow(1)
|
||||
})
|
||||
res.Table.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
func evalRank(e EvalCase) int {
|
||||
if e.Passed {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func evalSample() []EvalCase {
|
||||
return []EvalCase{
|
||||
{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true},
|
||||
{ID: "c2", Name: "Refuses forgery", Score: 0.41},
|
||||
{ID: "c3", Name: "Summarizes letter", Score: 0.87, Passed: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableSummaryAndDiff(t *testing.T) {
|
||||
var s data.DataTableState[string]
|
||||
diff := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
r := EvalResultTable(c, &s, evalSample(), EvalResultTableOptions{})
|
||||
r.Table.Element.Grow(1)
|
||||
if id, ok := r.Diff(); ok {
|
||||
diff = id
|
||||
}
|
||||
})
|
||||
}, 700, 420)
|
||||
for _, want := range []string{"2 of 3 passed", "0.41", "Fail", "Pass"} {
|
||||
if !tt.HasText(want) {
|
||||
t.Errorf("missing %q: %q", want, tt.Texts())
|
||||
}
|
||||
}
|
||||
if err := tt.Click("View differences for Refuses forgery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "c2" {
|
||||
t.Fatalf("diff %q", diff)
|
||||
}
|
||||
if err := tt.Click("Score"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Sort.Column != "score" {
|
||||
t.Fatalf("sort %+v", s.Sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableInvalid(t *testing.T) {
|
||||
for _, cases := range [][]EvalCase{{{Name: "x"}}, {{ID: "a", Score: nanValue()}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v did not panic", cases)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.DataTableState[string]
|
||||
EvalResultTable(c, &s, cases, EvalResultTableOptions{})
|
||||
}, 300, 200, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableSnapshot(t *testing.T) {
|
||||
cases := evalSample()
|
||||
snapshot(t, "eval_result_table", 640, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *data.DataTableState[string] { return &data.DataTableState[string]{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
EvalResultTable(c, *s, cases, EvalResultTableOptions{}).Table.Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvalResultTableDuplicateIDs(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("duplicate case IDs accepted")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var state data.DataTableState[string]
|
||||
EvalResultTable(c, &state, []EvalCase{{ID: "a"}, {ID: "a"}}, EvalResultTableOptions{})
|
||||
}, 500, 300, 1)
|
||||
}
|
||||
|
||||
func TestEvalResultTableDisabledKeyboardDiff(t *testing.T) {
|
||||
var state data.DataTableState[string]
|
||||
disabled := false
|
||||
diffs := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r := EvalResultTable(c, &state, evalSample(), EvalResultTableOptions{})
|
||||
r.Table.Element.Height(300)
|
||||
if _, ok := r.Diff(); ok {
|
||||
diffs++
|
||||
}
|
||||
})
|
||||
}, 700, 400)
|
||||
tabTo(t, tt, "View differences for Greets by title")
|
||||
for i := 0; i < 8 && diffs == 0; i++ {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
}
|
||||
if diffs != 1 {
|
||||
t.Fatalf("keyboard diffs %d", diffs)
|
||||
}
|
||||
disabled = true
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("View differences for Greets by title")
|
||||
if diffs != 1 {
|
||||
t.Fatalf("disabled diffs %d", diffs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalResultTableLargeDataAndVersion(t *testing.T) {
|
||||
cases := make([]EvalCase, 100000)
|
||||
for i := range cases {
|
||||
cases[i] = EvalCase{ID: strconv.Itoa(i), Name: "Case " + strconv.Itoa(i), Score: 1, Passed: true}
|
||||
}
|
||||
var s data.DataTableState[string]
|
||||
version := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
EvalResultTable(c, &s, cases, EvalResultTableOptions{Version: version}).Element.Height(300)
|
||||
}, 700, 400)
|
||||
a, b := s.VisibleRows()
|
||||
if b-a > 30 || !tt.HasText("100000 of 100000 passed") {
|
||||
t.Fatalf("rows %d summary missing", b-a)
|
||||
}
|
||||
cases[0].Passed = false
|
||||
version++
|
||||
tt.Frame()
|
||||
if !tt.HasText("99999 of 100000 passed") {
|
||||
t.Fatal("stale summary")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
)
|
||||
|
||||
func ExampleUndoToast() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.UndoToast(c, &open, "Note deleted", feedback.UndoToastOptions{
|
||||
OnUndo: func() {
|
||||
// restore the note
|
||||
},
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleActivityFeed() {
|
||||
events := []feedback.Activity{
|
||||
{ID: "1", Actor: "Lin", Action: "commented on", Object: "Quarterly report", Time: time.Now()},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.ActivityFeed(c, events, feedback.ActivityFeedOptions{}).Size(380, 280)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func ExampleAlert() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.Alert(c, &open, "The archive could not be read.", feedback.AlertOptions{
|
||||
Severity: core.SeverityError,
|
||||
Title: "Import failed",
|
||||
Action: "Retry",
|
||||
})
|
||||
if r.Action {
|
||||
// retry the import
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToaster() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Delete note", input.ButtonOptions{Variant: input.Danger}).Clicked() {
|
||||
feedback.ShowToast(c, feedback.ToastMessage{ID: "note-1", Message: "Note deleted", Action: "Undo"})
|
||||
}
|
||||
if r := feedback.Toaster(c); r.Action == "note-1" {
|
||||
// restore the note
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleProgress() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(16).Gap(16).Children(func() {
|
||||
feedback.Progress(c, 0.62, feedback.ProgressOptions{Label: "Uploading", ShowValue: true})
|
||||
feedback.Progress(c, 0, feedback.ProgressOptions{Shape: feedback.ProgressRing, Indeterminate: true, Label: "Connecting"})
|
||||
feedback.Meter(c, 93, feedback.MeterOptions{
|
||||
Max: 100, Label: "Disk", ShowValue: true,
|
||||
Levels: &ui.MeterLevels{Warning: 70, Critical: 90},
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSkeleton() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Padding(16).Gap(12).Children(func() {
|
||||
feedback.Skeleton(c, feedback.SkeletonOptions{Shape: feedback.SkeletonCircle})
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
feedback.Skeleton(c, feedback.SkeletonOptions{Lines: 2})
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEmptyState() {
|
||||
query := "quarterly"
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.EmptyState(c, feedback.EmptyStateOptions{
|
||||
Kind: feedback.EmptyNoResults,
|
||||
Query: query,
|
||||
Action: "Clear search",
|
||||
})
|
||||
if r.Action {
|
||||
query = ""
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleResult() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.Result(c, feedback.ResultOptions{
|
||||
Kind: feedback.ResultForbidden,
|
||||
Description: "You need the Editor role to change billing.",
|
||||
Actions: []string{"Request access", "Go back"},
|
||||
})
|
||||
switch r.Action {
|
||||
case 0: // send the request
|
||||
case 1: // navigate back
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStatusIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(16).Children(func() {
|
||||
feedback.StatusIndicator(c, feedback.StatusSuccess, feedback.StatusIndicatorOptions{Label: "API online"})
|
||||
feedback.StatusIndicator(c, feedback.StatusOffline, feedback.StatusIndicatorOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleNotificationCenter() {
|
||||
items := []feedback.Notification{
|
||||
{ID: "a", Title: "Build failed", Body: "3 tests failing", Time: "2 min ago", Action: "View log"},
|
||||
{ID: "b", Title: "Backup complete", Time: "Yesterday", Read: true},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.NotificationCenter(c, &items, feedback.NotificationCenterOptions{})
|
||||
if r.Action == "a" {
|
||||
// open the build log
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLoadingOverlay() {
|
||||
loading := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.LoadingOverlay(c, loading, feedback.LoadingOverlayOptions{Cancel: "Cancel"}, func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
input.Button(c, "Export", input.ButtonOptions{})
|
||||
})
|
||||
if r.Canceled {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
|
||||
)
|
||||
|
||||
func ExampleInterruptButton() {
|
||||
running, stopping := true, false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.InterruptButton(c, running, feedback.InterruptButtonOptions{Confirm: true, Interrupting: stopping})
|
||||
if r.Changed() {
|
||||
stopping = true // cancel the run, then clear running and stopping
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTokenUsageChart() {
|
||||
usage := []feedback.TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Write", Input: 2100, Output: 2900}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.TokenUsageChart(c, usage, feedback.TokenUsageChartOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCostBreakdown() {
|
||||
var state feedback.CostBreakdownState
|
||||
items := []feedback.CostItem{
|
||||
{Model: "opus", Tool: "search", InputTokens: 1000, OutputTokens: 200, Cost: 0.30},
|
||||
{Model: "haiku", Tool: "search", InputTokens: 4000, OutputTokens: 500, Cost: 0.05},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.CostBreakdown(c, &state, items, feedback.CostBreakdownOptions{GroupBy: feedback.CostByModel}).Element.Height(260)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTraceViewer() {
|
||||
var state feedback.TraceViewerState
|
||||
spans := []feedback.TraceSpan{
|
||||
{ID: "run", Name: "agent.run", End: 900 * time.Millisecond},
|
||||
{ID: "plan", Parent: "run", Name: "llm.plan", Start: 10 * time.Millisecond, End: 210 * time.Millisecond,
|
||||
Attrs: []feedback.TraceAttr{{Key: "model", Value: "opus"}}},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.TraceViewer(c, &state, spans, feedback.TraceViewerOptions{}).Element.Height(260)
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleEvalResultTable() {
|
||||
var state data.DataTableState[string]
|
||||
cases := []feedback.EvalCase{{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, {ID: "c2", Name: "Refuses forgery", Score: 0.41}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.EvalResultTable(c, &state, cases, feedback.EvalResultTableOptions{})
|
||||
r.Table.Element.Height(260)
|
||||
if id, ok := r.Diff(); ok {
|
||||
_ = id // open expected and actual output side by side
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolRegistryPanel() {
|
||||
var state data.ListState[string]
|
||||
tools := []feedback.AgentTool{{Name: "web_search", Description: "Search the web", Enabled: true,
|
||||
Params: []feedback.ToolParam{{Name: "query", Type: "string", Required: true}}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.ToolRegistryPanel(c, &state, tools, feedback.ToolRegistryPanelOptions{})
|
||||
r.Element.Height(260)
|
||||
if name, on, ok := r.Toggled(); ok && name == tools[0].Name {
|
||||
tools[0].Enabled = on
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDotsLoader() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.DotsLoader(c, feedback.LoaderOptions{Label: "Thinking", ShowLabel: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBarsLoader() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.BarsLoader(c, feedback.LoaderOptions{Size: 20, Label: "Indexing"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePulseLoader() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.PulseLoader(c, feedback.LoaderOptions{Label: "Listening", ShowLabel: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleOrbitLoader() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.OrbitLoader(c, feedback.LoaderOptions{Size: 24, Label: "Connecting"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWaveLoader() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.WaveLoader(c, feedback.LoaderOptions{Label: "Speaking", ShowLabel: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleShimmer() {
|
||||
loading := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.Shimmer(c, loading, feedback.ShimmerOptions{Height: 48}, func() {
|
||||
ui.Text(c, "Report ready")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCountUp() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.CountUp(c, 12840, feedback.CountUpOptions{Duration: 800 * time.Millisecond})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTypewriter() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := feedback.Typewriter(c, "The court assembles at dusk.", feedback.TypewriterOptions{Speed: 30, Skippable: true})
|
||||
if r.Changed() {
|
||||
// show the next message
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleShimmerText() {
|
||||
thinking := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.ShimmerText(c, "Thinking…", feedback.ShimmerTextOptions{Active: thinking})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleStagger() {
|
||||
guests := []string{"Sakiko", "Uika", "Mutsumi"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.Stagger(c, len(guests), feedback.StaggerOptions{}, func(i int) {
|
||||
ui.Text(c, guests[i])
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleReorderTransition() {
|
||||
order := []string{"Sakiko", "Uika", "Mutsumi"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.ReorderTransition(c, order, feedback.ReorderTransitionOptions{Gap: 4}, func(name string) {
|
||||
ui.Text(c, name)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLayoutTransition() {
|
||||
open := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.LayoutTransition(c, feedback.LayoutTransitionOptions{Clip: true}, func() {
|
||||
ui.Text(c, "Program")
|
||||
if open {
|
||||
ui.Text(c, "Overture · Masquerade · Finale")
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePresence() {
|
||||
show := true
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.Presence(c, show, feedback.PresenceOptions{Kind: feedback.PresenceSlide}, func() {
|
||||
ui.Text(c, "Saved")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBlinkHighlight() {
|
||||
price := 101.5
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
feedback.BlinkHighlight(c, price, feedback.BlinkHighlightOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestFeedbackTextFollowsTextScale(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}, {ID: "u", Title: "Unread one"}}
|
||||
var empty, result float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
Progress(c, 0.5, ProgressOptions{ShowValue: true})
|
||||
empty = EmptyState(c, EmptyStateOptions{Title: "Empty title", Description: "Short", NoIcon: true}).Element.Bounds().H
|
||||
result = Result(c, ResultOptions{Title: "Result title"}).Element.Bounds().H
|
||||
StatusIndicator(c, StatusSuccess, StatusIndicatorOptions{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
}, 900, 2400)
|
||||
tt.Frame()
|
||||
empty1, result1 := empty, result
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
tt.Frame() // Bounds report the previous layout
|
||||
for text, min := range map[string]float32{
|
||||
"50%": theme.CaptionSize * 2,
|
||||
"Healthy": theme.BodySize * 2,
|
||||
"1 unread": theme.CaptionSize * 2,
|
||||
} {
|
||||
if r, ok := tt.Find(text); !ok || r.H < min {
|
||||
t.Errorf("%q at 200%%: %v %v, want height >= %v", text, r, ok, min)
|
||||
}
|
||||
}
|
||||
// Titles are found only through their labeled groups: the groups must grow by the titles too.
|
||||
if grow := empty - empty1; grow < theme.BodySize*theme.LineHeight+theme.H3Size {
|
||||
t.Errorf("EmptyState grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
if grow := result - result1; grow < theme.H2Size {
|
||||
t.Errorf("Result grew %v at 200%%; its title did not scale", grow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackRejectsInvalidInput(t *testing.T) {
|
||||
nan, inf := math.NaN(), math.Inf(1)
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"progress shape": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Shape: 7}) },
|
||||
"progress NaN size": func(c *ui.Context) { Progress(c, 0.5, ProgressOptions{Size: float32(nan)}) },
|
||||
"meter infinite range": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: inf}) },
|
||||
"meter NaN min": func(c *ui.Context) { Meter(c, 1, MeterOptions{Min: nan, Max: 2}) },
|
||||
"meter NaN threshold": func(c *ui.Context) {
|
||||
Meter(c, 1, MeterOptions{Max: 2, Levels: &ui.MeterLevels{Warning: nan, Critical: 2}})
|
||||
},
|
||||
"skeleton shape": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Shape: 5}) },
|
||||
"skeleton NaN width": func(c *ui.Context) { Skeleton(c, SkeletonOptions{Width: float32(nan)}) },
|
||||
"spinner negative size": func(c *ui.Context) { core.Spinner(c, core.SpinnerOptions{Size: -4}) },
|
||||
"status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) },
|
||||
"alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) },
|
||||
"toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) },
|
||||
"meter negative width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: -1}) },
|
||||
"meter NaN width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(nan)}) },
|
||||
"meter infinite width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(inf)}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationTitleOnlyHitHeight(t *testing.T) {
|
||||
items := []Notification{{ID: "n", Title: "Backup complete", Read: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
}, 600, 400)
|
||||
if r, ok := tt.Find("Backup complete"); !ok || r.H < theme.MinHitSize {
|
||||
t.Fatalf("title-only notification hit box %v %v", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHeaderWrapsAtDoubleText(t *testing.T) {
|
||||
items := []Notification{{ID: "a", Title: "Build failed"}, {ID: "b", Title: "Backup complete", Read: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Width(520).Height(700).Children(func() {
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
}, 600, 760)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
for _, s := range []string{"Notifications", "1 unread", "Mark all read", "Clear all"} {
|
||||
if r, ok := tt.Find(s); !ok || r.W == 0 || r.H == 0 || r.X < 0 || r.X+r.W > 520 {
|
||||
t.Errorf("%q at 200%%: %v %v", s, r, ok)
|
||||
}
|
||||
}
|
||||
if err := tt.Click("Clear all"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("Clear all at 200%% left %d items", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressRingCaptionFitsAtDoubleText(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Children(func() {
|
||||
Progress(c, 1, ProgressOptions{Shape: ProgressRing, ShowValue: true})
|
||||
})
|
||||
}, 400, 200)
|
||||
for _, scale := range []float32{1, 2} {
|
||||
tt.SetPreferences(ui.Preferences{TextScale: scale})
|
||||
tt.Frame()
|
||||
ring, ok := tt.Find("Loading")
|
||||
text, ok2 := tt.Find("100%")
|
||||
if !ok || !ok2 {
|
||||
t.Fatalf("scale %v: ring %v text %v", scale, ok, ok2)
|
||||
}
|
||||
stroke := max(float32(3), ring.W/10)
|
||||
inside := text.X >= ring.X+stroke && text.X+text.W <= ring.X+ring.W-stroke
|
||||
outside := text.X >= ring.X+ring.W
|
||||
if !inside && !outside {
|
||||
t.Errorf("scale %v: caption %v clipped by ring %v", scale, text, ring)
|
||||
}
|
||||
if scale == 1 && !inside {
|
||||
t.Errorf("caption left the ring at 100%%: %v", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressResumesFromDisplayedValueAfterReducedMotion(t *testing.T) {
|
||||
v := 0.0
|
||||
var bar ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, v, ProgressOptions{}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
v = 0.5
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
if got, want := progressPixel(tt, bar.X+bar.W*0.45, bar.Y+bar.H/2), theme.Light().AccentText; got != want {
|
||||
t.Fatalf("after motion returned the fill at 45%% is %v, want %v", got, want)
|
||||
}
|
||||
v = 1
|
||||
tt.Frame()
|
||||
if got := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2); got == theme.Light().AccentText {
|
||||
t.Fatal("restored motion did not animate the next change")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func encode(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
return snaptest.Encode(t, img)
|
||||
}
|
||||
|
||||
// snapshotAfter is snapshot with drive sending input before the capture.
|
||||
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
|
||||
}
|
||||
|
||||
// tabTo presses Tab until the element labeled label has the focus.
|
||||
func tabTo(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
for range 60 {
|
||||
if tt.Focused(label) {
|
||||
return
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
t.Fatalf("Tab never reached %q", label)
|
||||
}
|
||||
|
||||
type chartHarness struct {
|
||||
res chart.ChartResult
|
||||
changes int
|
||||
disabled bool
|
||||
build func(c *ui.Context) chart.ChartResult
|
||||
}
|
||||
|
||||
// chartReadout returns the readout line, the text after the plot.
|
||||
func chartReadout(tt *ui.Tester) string {
|
||||
texts := tt.Texts()
|
||||
return texts[len(texts)-1]
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
|
||||
func (h *chartHarness) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Padding(16).Width(480).Disabled(h.disabled).Children(func() {
|
||||
h.res = h.build(c)
|
||||
if h.res.Changed() {
|
||||
h.changes++
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
)
|
||||
|
||||
// InterruptButtonOptions configure an InterruptButton.
|
||||
type InterruptButtonOptions struct {
|
||||
// Confirm asks before interrupting.
|
||||
Confirm bool
|
||||
// Interrupting shows the stop in progress until the run ends.
|
||||
Interrupting bool
|
||||
// Label replaces the built-in "Stop".
|
||||
Label string
|
||||
}
|
||||
|
||||
// InterruptButton stops a running agent; Changed reports the interrupt.
|
||||
func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) core.ChoiceResult {
|
||||
if opts.Interrupting && !running {
|
||||
panic("mujica: InterruptButton cannot be Interrupting while not running")
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "interrupt_button.stop")
|
||||
}
|
||||
shown := label
|
||||
if opts.Interrupting {
|
||||
shown = core.Msg(c, "interrupt_button.stopping")
|
||||
}
|
||||
live := running && !opts.Interrupting
|
||||
var res core.ChoiceResult
|
||||
wrap := ui.Row(c).Shrink(0)
|
||||
res.Element = wrap
|
||||
wrap.Children(func() {
|
||||
b := input.Button(c, shown, input.ButtonOptions{Variant: input.Danger, Icon: icons.Must("circle-x"), Loading: opts.Interrupting, Disabled: !running})
|
||||
open := ui.Local(wrap, "confirm", func() bool { return false })
|
||||
if !live || b.IsDisabled() {
|
||||
*open = false
|
||||
}
|
||||
clicked := b.Clicked() && live && !b.IsDisabled()
|
||||
switch {
|
||||
case clicked && opts.Confirm:
|
||||
*open = true
|
||||
case clicked:
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
r := overlay.Popconfirm(c, b, open, overlay.PopconfirmOptions{Title: core.Msg(c, "interrupt_button.confirm"),
|
||||
Description: core.Msg(c, "interrupt_button.detail"), ConfirmLabel: label, CancelLabel: core.Msg(c, "interrupt_button.keep"), Danger: true})
|
||||
if r.Confirmed && live && !b.IsDisabled() {
|
||||
res = core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
})
|
||||
if res.Changed() {
|
||||
slog.Debug("mujica: interrupt button interrupt")
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestInterruptButtonDirectAndConfirmed(t *testing.T) {
|
||||
running, interrupting, confirm := true, false, false
|
||||
stops := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
if InterruptButton(c, running, InterruptButtonOptions{Confirm: confirm, Interrupting: interrupting}).Changed() {
|
||||
stops++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
if err := tt.Click("Stop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stops != 1 {
|
||||
t.Fatalf("direct stop %d", stops)
|
||||
}
|
||||
confirm = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stop")
|
||||
if stops != 1 || !tt.HasText("Stop the agent?") {
|
||||
t.Fatalf("confirm not asked: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Keep running")
|
||||
if stops != 1 || tt.HasText("Stop the agent?") {
|
||||
t.Fatal("cancel interrupted")
|
||||
}
|
||||
_ = tt.Click("Stop")
|
||||
tt.Key(0, ui.KeyTab) // Danger focuses Cancel first; Tab reaches Stop
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if stops != 2 {
|
||||
t.Fatalf("confirmed stop %d: %q", stops, tt.Texts())
|
||||
}
|
||||
interrupting = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stopping")
|
||||
if stops != 2 || !tt.HasText("Stopping") {
|
||||
t.Fatalf("interrupting: stops %d %q", stops, tt.Texts())
|
||||
}
|
||||
interrupting, running = false, false
|
||||
tt.Frame()
|
||||
_ = tt.Click("Stop")
|
||||
if stops != 2 {
|
||||
t.Fatal("idle button interrupted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterruptButtonInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
InterruptButton(c, false, InterruptButtonOptions{Interrupting: true})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestInterruptButtonSnapshot(t *testing.T) {
|
||||
snapshot(t, "interrupt_button", 460, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).Children(func() {
|
||||
InterruptButton(c, true, InterruptButtonOptions{})
|
||||
InterruptButton(c, true, InterruptButtonOptions{Interrupting: true})
|
||||
InterruptButton(c, false, InterruptButtonOptions{Label: "Cancel run"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestInterruptButtonAncestorDisabledClosesConfirmation(t *testing.T) {
|
||||
disabled := false
|
||||
stops := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
if InterruptButton(c, true, InterruptButtonOptions{Confirm: true}).Changed() {
|
||||
stops++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
_ = tt.Click("Stop")
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Stop the agent?") || stops != 0 {
|
||||
t.Fatal("disabled confirmation retained")
|
||||
}
|
||||
_ = tt.Click("Stop")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if stops != 0 {
|
||||
t.Fatal("disabled interrupt delivered")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LayoutTransitionOptions configure a LayoutTransition.
|
||||
type LayoutTransitionOptions struct {
|
||||
// Duration of a change; zero means StateDuration.
|
||||
Duration time.Duration
|
||||
// Row lays the content in a row.
|
||||
Row bool
|
||||
// Clip hides content outside the box while it resizes.
|
||||
Clip bool
|
||||
}
|
||||
|
||||
// LayoutTransition animates its size and place when they change.
|
||||
func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) *ui.Element {
|
||||
if opts.Duration < 0 {
|
||||
panic("mujica: LayoutTransition Duration is negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.StateDuration
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if opts.Row {
|
||||
box.Row()
|
||||
}
|
||||
if opts.Clip {
|
||||
box.Clip()
|
||||
}
|
||||
box.Transition(ui.ElementTransition{Duration: core.Motion(c, d), Position: true, Size: true})
|
||||
if content != nil {
|
||||
box.Children(content)
|
||||
}
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestLayoutTransitionResizes(t *testing.T) {
|
||||
wide := false
|
||||
var w float32
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
box := LayoutTransition(c, LayoutTransitionOptions{Clip: true}, func() {
|
||||
width := float32(100)
|
||||
if wide {
|
||||
width = 300
|
||||
}
|
||||
ui.Box(c).Size(width, 40)
|
||||
})
|
||||
w = box.Bounds().W
|
||||
})
|
||||
}, 400, 120)
|
||||
wide = true
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatal("resize did not animate")
|
||||
}
|
||||
time.Sleep(260 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) || w != 300 {
|
||||
t.Fatalf("settled width %v", w)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative duration did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, nil)
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestLayoutTransitionSnapshot(t *testing.T) {
|
||||
snapshot(t, "layout_transition", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() {
|
||||
LayoutTransition(c, LayoutTransitionOptions{}, func() {
|
||||
ui.Text(c, "A panel that grows").Padding(12).Background(core.Tokens(c).Surface).Radius(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// motionFrames builds view and returns its tester and a counter of built frames.
|
||||
func motionFrames(view func(c *ui.Context), w, h int) (*ui.Tester, *int) {
|
||||
n := new(int)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
*n++
|
||||
core.Use(c, core.Settings{})
|
||||
view(c)
|
||||
}, w, h)
|
||||
return tt, n
|
||||
}
|
||||
|
||||
// motionBusy reports whether one more frame keeps asking for frames.
|
||||
func motionBusy(tt *ui.Tester, n *int) bool {
|
||||
before := *n
|
||||
tt.Frame()
|
||||
return *n-before > 2
|
||||
}
|
||||
|
||||
// motionCount counts texts and names equal to s.
|
||||
func motionCount(tt *ui.Tester, s string) int {
|
||||
n := 0
|
||||
for _, x := range tt.Texts() {
|
||||
if x == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var loaderCases = map[string]func(c *ui.Context, o LoaderOptions) *ui.Element{
|
||||
"dots": DotsLoader, "bars": BarsLoader, "pulse": PulseLoader, "orbit": OrbitLoader, "wave": WaveLoader,
|
||||
}
|
||||
|
||||
func testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) *ui.Element) {
|
||||
show := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
if show {
|
||||
loader(c, LoaderOptions{Size: 32, Label: "Thinking"})
|
||||
}
|
||||
}, 200, 80)
|
||||
if _, ok := tt.Find("Thinking"); !ok {
|
||||
t.Fatal("loader not named")
|
||||
}
|
||||
if motionCount(tt, "Thinking") != 1 {
|
||||
t.Fatal("label shown without ShowLabel")
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("loader does not move")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) || motionCount(tt, "Thinking") != 2 {
|
||||
t.Fatalf("reduced motion: still repainting or no static label: %q", tt.Texts())
|
||||
}
|
||||
a = encode(t, tt.Image())
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("loader moved under reduced motion")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
show = false
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("hidden loader keeps repainting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadersInvalidSize(t *testing.T) {
|
||||
for name, loader := range loaderCases {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: negative size did not panic", name)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
loader(c, LoaderOptions{Size: -1})
|
||||
}, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func loaderSnapshot(t *testing.T, name string, loader func(c *ui.Context, o LoaderOptions) *ui.Element) {
|
||||
snapshot(t, name, 480, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(24).AlignItems(ui.Center).Children(func() {
|
||||
loader(c, LoaderOptions{})
|
||||
loader(c, LoaderOptions{Size: 28, Label: "Thinking", ShowLabel: true})
|
||||
loader(c, LoaderOptions{Size: 20, Color: core.Tokens(c).Info})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 covers content while loading, blocking input to that region only.
|
||||
func LoadingOverlay(c *ui.Context, loading bool, opts LoadingOverlayOptions, content func()) LoadingOverlayResult {
|
||||
if content == nil {
|
||||
panic("mujica: LoadingOverlay needs content")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := cmp.Or(opts.Label, core.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() {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 20, Label: label})
|
||||
ui.Text(c, label).TextColor(k.Text)
|
||||
})
|
||||
if opts.Cancel == "" {
|
||||
return
|
||||
}
|
||||
b := input.Button(c, opts.Cancel, input.ButtonOptions{Variant: input.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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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 input.Button(c, "Save", input.ButtonOptions{}).Clicked() {
|
||||
p.saves++
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.Canceled {
|
||||
p.cancels++
|
||||
}
|
||||
if input.Button(c, "Outside", input.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).AlignItems(ui.Start).Background(core.Tokens(c).Surface).Radius(4).Children(func() {
|
||||
ui.Text(c, "Quarterly report")
|
||||
ui.Text(c, "Revenue, churn and pipeline for Q3.")
|
||||
input.Button(c, "Export", input.ButtonOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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.
|
||||
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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
title := cmp.Or(opts.Title, core.Msg(c, "notification_center.title"))
|
||||
var res NotificationCenterResult
|
||||
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() {
|
||||
// Wraps so title and actions stay whole when text is enlarged.
|
||||
ui.Row(c).Wrap().AlignItems(ui.Center).Justify(ui.SpaceBetween).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).TextColor(k.Text).SingleLine()
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "notification_center.unread"), unread)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).SingleLine()
|
||||
}
|
||||
})
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
if input.Button(c, core.Msg(c, "notification_center.mark_all_read"), input.ButtonOptions{Variant: input.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 input.Button(c, core.Msg(c, "notification_center.clear_all"), input.ButtonOptions{Variant: input.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: core.Msg(c, "notification_center.empty"),
|
||||
Description: core.Msg(c, "notification_center.empty_hint"),
|
||||
Icon: icons.Must("bell"),
|
||||
})
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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 = core.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)
|
||||
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).MinHeight(theme.MinHitSize).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(core.FontSize(c, 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 != "" && input.Button(c, n.Action, input.ButtonOptions{Variant: input.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, core.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"), core.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 := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icon, Label: label})
|
||||
b.TextColor(k.TextMuted).Padding(0, u).Tooltip(label)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// OrbitLoader moves a diamond around a ring of diamonds.
|
||||
func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, "orbit_loader", 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.36
|
||||
at := func(turn float64) (float32, float32) {
|
||||
a := 2*math.Pi*turn - math.Pi/2
|
||||
return internal.Snap(p, cx+rad*float32(math.Cos(a))), internal.Snap(p, cy+rad*float32(math.Sin(a)))
|
||||
}
|
||||
for i := range 8 {
|
||||
x, y := at(float64(i) / 8)
|
||||
p.FillPath(internal.Diamond(x, y, r.W*0.12), col.Alpha(0.3))
|
||||
}
|
||||
x, y := at(t)
|
||||
p.FillPath(internal.Diamond(x, y, internal.Snap(p, r.W*0.28)), col)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrbitLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "orbit_loader", OrbitLoader) }
|
||||
|
||||
func TestOrbitLoaderBehavior(t *testing.T) { testLoaderBehavior(t, OrbitLoader) }
|
||||
@@ -0,0 +1,76 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// PresenceKind is how content comes and goes.
|
||||
type PresenceKind int
|
||||
|
||||
const (
|
||||
// PresenceFade fades in and out.
|
||||
PresenceFade PresenceKind = iota
|
||||
// PresenceSlide fades while moving up into place and down out.
|
||||
PresenceSlide
|
||||
// PresenceCollapse grows open and shrinks shut along the parent's direction.
|
||||
PresenceCollapse
|
||||
)
|
||||
|
||||
// PresenceOptions configure a Presence.
|
||||
type PresenceOptions struct {
|
||||
Kind PresenceKind
|
||||
// Duration is PopupDuration when zero.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// presenceMotion is where content comes from and goes.
|
||||
func presenceMotion(kind PresenceKind) *ui.Motion {
|
||||
switch kind {
|
||||
case PresenceFade:
|
||||
return &ui.Motion{}
|
||||
case PresenceSlide:
|
||||
return &ui.Motion{Y: 8}
|
||||
case PresenceCollapse:
|
||||
return &ui.Motion{Collapse: true, Opacity: 1}
|
||||
}
|
||||
panic("mujica: unknown PresenceKind")
|
||||
}
|
||||
|
||||
type presenceState struct{ shown bool }
|
||||
|
||||
// Presence builds content while show, entering and exiting with motion.
|
||||
func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element {
|
||||
m := presenceMotion(opts.Kind)
|
||||
if opts.Duration < 0 {
|
||||
panic("mujica: Presence Duration is negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.PopupDuration
|
||||
}
|
||||
holder := ui.Column(c).AlignItems(ui.Stretch).Shrink(0)
|
||||
st := ui.Local(holder, "presence", func() presenceState { return presenceState{shown: show} })
|
||||
if st.shown != show {
|
||||
st.shown = show
|
||||
slog.Debug("mujica: presence", "show", show)
|
||||
}
|
||||
holder.Children(func() {
|
||||
if !show {
|
||||
if c.Preferences().ReduceMotion {
|
||||
// Rebuilding the empty slot cancels MyGo's active exit.
|
||||
ui.Column(c).Key("presence").Invisible().Transition(ui.ElementTransition{Duration: d, Position: true})
|
||||
}
|
||||
return
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch).Key("presence")
|
||||
box.Transition(ui.ElementTransition{Duration: core.Motion(c, d), Enter: m, Exit: m})
|
||||
box.Children(content)
|
||||
})
|
||||
return holder
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestPresenceEntersExitsAndRemoves(t *testing.T) {
|
||||
for _, kind := range []PresenceKind{PresenceFade, PresenceSlide, PresenceCollapse} {
|
||||
show := false
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() {
|
||||
Presence(c, show, PresenceOptions{Kind: kind}, func() { ui.Text(c, "Notice") })
|
||||
ui.Text(c, "Below")
|
||||
})
|
||||
}, 300, 120)
|
||||
if tt.HasText("Notice") {
|
||||
t.Fatal("hidden content built")
|
||||
}
|
||||
show = true
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if !tt.HasText("Notice") || *n-before < 3 {
|
||||
t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
show = false
|
||||
before = *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatalf("kind %d: no exit motion", kind)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Notice") || motionBusy(tt, n) {
|
||||
t.Fatalf("kind %d: not removed after exit", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceReducedMotionAndInvalid(t *testing.T) {
|
||||
show := false
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() { Presence(c, show, PresenceOptions{Kind: PresenceSlide}, func() { ui.Text(c, "Notice") }) })
|
||||
}, 300, 120)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
show = true
|
||||
if motionBusy(tt, n) || !tt.HasText("Notice") {
|
||||
t.Fatal("reduced motion animated")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown kind did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestPresenceSnapshot(t *testing.T) {
|
||||
snapshot(t, "presence", 300, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Presence(c, true, PresenceOptions{}, func() { ui.Text(c, "The doors open.") })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPresenceReducedMotionCancelsActiveExit(t *testing.T) {
|
||||
show := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
Presence(c, show, PresenceOptions{Duration: 5 * time.Second}, func() { ui.Text(c, "Leaving notice") })
|
||||
}, 300, 100)
|
||||
show = false
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) || tt.HasText("Leaving notice") {
|
||||
t.Fatal("active exit continued under reduced motion")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("exit replayed after restoring motion")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 work done, value 0 to 1; indeterminate ones hold still under reduced motion.
|
||||
func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
|
||||
if opts.Shape != ProgressLinear && opts.Shape != ProgressRing {
|
||||
panic(fmt.Sprintf("mujica: unknown ProgressShape %d", int(opts.Shape)))
|
||||
}
|
||||
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) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic(fmt.Sprintf("mujica: Progress Size %v invalid", opts.Size))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
still := c.Preferences().ReduceMotion
|
||||
name := opts.Label
|
||||
if name == "" {
|
||||
name = core.Msg(c, "loading")
|
||||
}
|
||||
acc := value
|
||||
if opts.Indeterminate {
|
||||
acc = -1
|
||||
}
|
||||
caption := ""
|
||||
switch {
|
||||
case opts.Indeterminate && still:
|
||||
caption = core.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(core.FontSize(c, 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 = progressAnimate(bar, shown, still)
|
||||
}
|
||||
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, 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 = progressAnimate(ring, shown, still)
|
||||
}
|
||||
stroke := max(3, d/10)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
tw, th := c.MeasureText(0, ui.Span{Text: caption, Size: size})
|
||||
inside := caption != "" && !opts.Indeterminate && tw+4 <= d-2*stroke && th <= d-2*stroke
|
||||
ring.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (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(core.ProgressArc(p, cx, cy, rad, 0, shown), stroke, k.AccentText)
|
||||
}
|
||||
case still:
|
||||
p.StrokePath(&track, stroke, k.AccentText)
|
||||
default:
|
||||
p.AnimationFrame()
|
||||
start := float32(p.Now().UnixMilli()%1200) / 1200
|
||||
p.StrokePath(core.ProgressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText)
|
||||
}
|
||||
})
|
||||
if inside {
|
||||
ring.Children(func() {
|
||||
ui.Text(c, caption).FontSize(size).FontFeatures("tnum").TextColor(k.Text)
|
||||
})
|
||||
ring.Center()
|
||||
}
|
||||
if opts.Label != "" || (caption != "" && !inside) {
|
||||
ui.Column(c).Children(func() {
|
||||
if opts.Label != "" {
|
||||
ui.Text(c, opts.Label).TextColor(k.Text)
|
||||
}
|
||||
if caption != "" && !inside {
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(size).FontFeatures("tnum")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// progressAnimate eases toward target; under reduced motion it lands there and keeps the state in step.
|
||||
func progressAnimate(e *ui.Element, target float32, still bool) float32 {
|
||||
if still {
|
||||
// A changed target lands at once under reduced motion; the detour stops a running transition.
|
||||
e.Animate("progress.value", target+1, theme.StateDuration)
|
||||
}
|
||||
return e.Animate("progress.value", target, theme.StateDuration)
|
||||
}
|
||||
|
||||
// MeterOptions configure a Meter.
|
||||
type MeterOptions struct {
|
||||
// Min and Max bound the value; both zero mean 0 to 1.
|
||||
Min, Max float64
|
||||
// Levels are the High and Critical thresholds; Critical below Warning flags low values.
|
||||
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 with threshold ticks and a level word, not color alone.
|
||||
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 !finite(lo) || !finite(hi) || !(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(fmt.Sprintf("mujica: Meter levels %+v outside %v..%v", *l, lo, hi))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.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 = core.Msg(c, "meter.normal")
|
||||
switch {
|
||||
case level == 2:
|
||||
word = core.Msg(c, "meter.critical")
|
||||
case level == 1 && low:
|
||||
word = core.Msg(c, "meter.low_warning")
|
||||
case level == 1:
|
||||
word = core.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) || math.IsInf(float64(opts.Width), 1) {
|
||||
panic(fmt.Sprintf("mujica: Meter Width %v invalid", opts.Width))
|
||||
}
|
||||
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(core.FontSize(c, 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
|
||||
}
|
||||
|
||||
func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
|
||||
@@ -0,0 +1,176 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestProgressReducedMotionJumpsMidTransition(t *testing.T) {
|
||||
v := 0.0
|
||||
var bar ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, v, ProgressOptions{}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
v = 1
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if got, want := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2), theme.Light().AccentText; got != want {
|
||||
t.Fatalf("fill at 95%% is %v, want %v at once", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func progressPixel(tt *ui.Tester, x, y float32) ui.Color {
|
||||
r, g, b, _ := tt.Image().At(int(x), int(y)).RGBA()
|
||||
return ui.RGB(uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
}
|
||||
|
||||
func TestProgressStaticFillContrast(t *testing.T) {
|
||||
for _, m := range []core.Mode{core.Light, core.Dark} {
|
||||
var bar ui.Rect
|
||||
var k theme.Tokens
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
k = core.Tokens(c)
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, 0, ProgressOptions{Indeterminate: true}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame() // Bounds report the previous layout
|
||||
fill := progressPixel(tt, bar.X+bar.W/2, bar.Y+bar.H-3) // the bar is the last row
|
||||
for _, against := range []ui.Color{k.Border, k.Background} {
|
||||
if cr := theme.Contrast(fill, against); cr < 3 {
|
||||
t.Errorf("%v: static fill %v vs %v is %.2f:1", m, fill, against, cr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressValueText(t *testing.T) {
|
||||
v := 0.424
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Locale: core.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) { core.Use(c, core.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,21 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// PulseLoader shows one dot gently breathing, without a halo.
|
||||
func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, "pulse_loader", 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
s, a := float32(1), float32(1)
|
||||
if !still {
|
||||
w := loaderWave(t)
|
||||
s, a = 0.6+0.4*w, 0.55+0.45*w
|
||||
}
|
||||
var dot ui.Path
|
||||
dot.Circle(r.X+r.W/2, r.Y+r.H/2, internal.Snap(p, r.H*0.4*s))
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPulseLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "pulse_loader", PulseLoader) }
|
||||
|
||||
func TestPulseLoaderBehavior(t *testing.T) { testLoaderBehavior(t, PulseLoader) }
|
||||
@@ -0,0 +1,67 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ReorderTransitionOptions configure a ReorderTransition.
|
||||
type ReorderTransitionOptions struct {
|
||||
// Duration of a move; zero means StateDuration.
|
||||
Duration time.Duration
|
||||
// Gap between items in DIPs.
|
||||
Gap float32
|
||||
}
|
||||
|
||||
// ReorderTransition slides keyed items to their new places.
|
||||
func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element {
|
||||
if opts.Duration < 0 || !(opts.Gap >= 0) || math.IsInf(float64(opts.Gap), 0) {
|
||||
panic("mujica: ReorderTransition Duration and Gap must not be negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.StateDuration
|
||||
}
|
||||
seen := make(map[K]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
if seen[k] {
|
||||
panic(fmt.Sprintf("mujica: ReorderTransition duplicate key %v", k))
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
move := ui.ElementTransition{Duration: core.Motion(c, d), Position: true, Enter: &ui.Motion{}, Exit: &ui.Motion{}}
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(opts.Gap)
|
||||
type state struct {
|
||||
keys []K
|
||||
leaving map[K]time.Time
|
||||
}
|
||||
st := ui.Local(col, "reorder", func() state { return state{leaving: map[K]time.Time{}} })
|
||||
for _, k := range st.keys {
|
||||
if !seen[k] {
|
||||
st.leaving[k] = c.Now().Add(d)
|
||||
}
|
||||
}
|
||||
st.keys = append(st.keys[:0], keys...)
|
||||
col.Children(func() {
|
||||
for _, k := range keys {
|
||||
ui.Column(c).Key(k).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) })
|
||||
}
|
||||
for k, until := range st.leaving {
|
||||
if seen[k] || !c.Now().Before(until) {
|
||||
delete(st.leaving, k)
|
||||
continue
|
||||
}
|
||||
if c.Preferences().ReduceMotion {
|
||||
ui.Column(c).Key(k).Absolute().Invisible().Transition(ui.ElementTransition{Duration: d, Position: true})
|
||||
delete(st.leaving, k)
|
||||
}
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestReorderTransitionSlides(t *testing.T) {
|
||||
keys := []string{"a", "b", "c"}
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ReorderTransition(c, keys, ReorderTransitionOptions{Gap: 4}, func(k string) {
|
||||
ui.Text(c, "Item "+k).Padding(8)
|
||||
})
|
||||
}, 300, 200)
|
||||
ra, _ := tt.Find("Item a")
|
||||
keys = []string{"c", "b", "a"}
|
||||
before := *n
|
||||
tt.Frame()
|
||||
if *n-before < 3 {
|
||||
t.Fatal("reorder did not animate")
|
||||
}
|
||||
mid, _ := tt.Find("Item a")
|
||||
time.Sleep(260 * time.Millisecond)
|
||||
tt.Frame()
|
||||
end, _ := tt.Find("Item a")
|
||||
if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) {
|
||||
t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("duplicate key did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestReorderTransitionSnapshot(t *testing.T) {
|
||||
snapshot(t, "reorder_transition", 300, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ReorderTransition(c, []string{"Sakiko", "Uika", "Mutsumi"}, ReorderTransitionOptions{Gap: 4}, func(k string) {
|
||||
ui.Text(c, k).Padding(4, 8).Background(core.Tokens(c).Surface).Radius(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderTransitionInvalidGap(t *testing.T) {
|
||||
for _, gap := range []float32{float32(nanValue()), float32(math.Inf(1))} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("invalid gap %v accepted", gap)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ReorderTransition(c, []int{1}, ReorderTransitionOptions{Gap: gap}, func(int) {})
|
||||
}, 200, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderTransitionReducedMotionCancelsExit(t *testing.T) {
|
||||
keys := []string{"a", "b"}
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ReorderTransition(c, keys, ReorderTransitionOptions{Duration: 5 * time.Second}, func(k string) { ui.Text(c, k) })
|
||||
}, 300, 150)
|
||||
keys = []string{"b"}
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) || tt.HasText("a") {
|
||||
t.Fatal("removed item kept animating under reduced motion")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 shows a task outcome and reports the chosen next step; the caller navigates.
|
||||
func Result(c *ui.Context, opts ResultOptions) ResultResult {
|
||||
k := core.Tokens(c)
|
||||
u := core.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, core.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(core.FontSize(c, 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 := input.Secondary
|
||||
if i == 0 {
|
||||
v = input.Primary
|
||||
}
|
||||
if input.Button(c, a, input.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) {
|
||||
internal.RuleDiamond(p, r, 9, 8, k.Ornament)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestResultActions(t *testing.T) {
|
||||
got := []int{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Locale: core.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) {
|
||||
core.Use(c, core.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) { core.Use(c, core.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"},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ShimmerOptions configure a Shimmer placeholder.
|
||||
type ShimmerOptions struct {
|
||||
// Width fills the parent when zero.
|
||||
Width float32
|
||||
// Height is ControlHeight when zero.
|
||||
Height float32
|
||||
// Label is read out while loading; empty means "Loading".
|
||||
Label string
|
||||
}
|
||||
|
||||
// Shimmer sweeps a placeholder while loading, then builds content.
|
||||
func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *ui.Element {
|
||||
if !(opts.Width >= 0) || !(opts.Height >= 0) || math.IsInf(float64(opts.Width+opts.Height), 1) {
|
||||
panic("mujica: Shimmer size must be finite and not negative")
|
||||
}
|
||||
if !loading {
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if content != nil {
|
||||
box.Children(content)
|
||||
}
|
||||
return box
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = core.Density(c).ControlHeight()
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "shimmer.loading")
|
||||
}
|
||||
fill := k.SurfaceHover
|
||||
if k.Dark {
|
||||
fill = k.Border
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
e := ui.Column(c).Height(h).AlignItems(ui.Stretch).Role(ui.RoleProgress).Label(label)
|
||||
if opts.Width > 0 {
|
||||
e.Width(opts.Width)
|
||||
} else {
|
||||
e.FillWidth()
|
||||
}
|
||||
// Keyed by motion so a flip repaints.
|
||||
e.Children(func() {
|
||||
ui.Box(c).Key(still).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, fill, theme.CardRadius)
|
||||
if still {
|
||||
return
|
||||
}
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1600) / 1600
|
||||
band := max(r.W*0.25, 24)
|
||||
x := internal.Snap(p, r.X-band+(r.W+band)*t)
|
||||
p.Clip(r, theme.CardRadius, func() {
|
||||
p.Fill(ui.Rect{X: x, Y: r.Y, W: band, H: r.H}, k.Surface.Alpha(0.55), 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestShimmerOnlyWhileLoading(t *testing.T) {
|
||||
loading := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
Shimmer(c, loading, ShimmerOptions{Height: 40, Label: "Loading report"}, func() { ui.Text(c, "Report ready") })
|
||||
}, 300, 100)
|
||||
if _, ok := tt.Find("Loading report"); !ok || tt.HasText("Report ready") {
|
||||
t.Fatalf("loading: %q", tt.Texts())
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("shimmer does not sweep")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("shimmer repaints under reduced motion")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
loading = false
|
||||
tt.Frame()
|
||||
if !tt.HasText("Report ready") || motionBusy(tt, n) {
|
||||
t.Fatalf("loaded: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShimmerInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Shimmer(c, true, ShimmerOptions{Width: -1}, nil)
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestShimmerSnapshot(t *testing.T) {
|
||||
snapshot(t, "shimmer", 400, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
Shimmer(c, true, ShimmerOptions{}, nil)
|
||||
Shimmer(c, true, ShimmerOptions{Width: 160, Height: 24}, nil)
|
||||
Shimmer(c, false, ShimmerOptions{}, func() { ui.Text(c, "Loaded content") })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// ShimmerTextOptions configure a ShimmerText.
|
||||
type ShimmerTextOptions struct {
|
||||
// Active sweeps the light; false shows plain muted text.
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ShimmerText sweeps light across a status such as "Thinking".
|
||||
func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element {
|
||||
if text == "" {
|
||||
panic("mujica: ShimmerText needs text")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
still := c.Preferences().ReduceMotion
|
||||
if !opts.Active || still {
|
||||
e := ui.Text(c, text).TextColor(k.TextMuted).SingleLine()
|
||||
if opts.Active {
|
||||
e.Role(ui.RoleStatus)
|
||||
}
|
||||
return e
|
||||
}
|
||||
runes := []rune(text)
|
||||
size := c.Theme().FontSize
|
||||
// The text keeps layout and name; the sweep paints over.
|
||||
e := ui.Text(c, text).TextColor(ui.Transparent).Role(ui.RoleStatus).SingleLine()
|
||||
e.DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1800) / 1800
|
||||
at := t*float32(len(runes)+6) - 3
|
||||
spans := make([]ui.Span, len(runes))
|
||||
for i, ch := range runes {
|
||||
d := max(0, 1-core.Abs32(float32(i)-at)/3)
|
||||
spans[i] = ui.Span{Text: string(ch), Size: size, Color: k.TextMuted.Mix(k.Text, d)}
|
||||
}
|
||||
p.Clip(r, 0, func() { p.RichText(r.X, r.Y, 0, spans...) })
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestShimmerTextSweepsAndStops(t *testing.T) {
|
||||
active := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ShimmerText(c, "Thinking", ShimmerTextOptions{Active: active})
|
||||
}, 200, 60)
|
||||
if _, ok := tt.Find("Thinking"); !ok {
|
||||
t.Fatal("text not named")
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("no sweep")
|
||||
}
|
||||
active = false
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) || !tt.HasText("Thinking") {
|
||||
t.Fatal("inactive text still sweeps")
|
||||
}
|
||||
active = true
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("sweeps under reduced motion")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("empty text did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ShimmerText(c, "", ShimmerTextOptions{})
|
||||
}, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestShimmerTextSnapshot(t *testing.T) {
|
||||
snapshot(t, "shimmer_text", 300, 80, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(8).Children(func() {
|
||||
ShimmerText(c, "Thinking…", ShimmerTextOptions{Active: true})
|
||||
ShimmerText(c, "思考中…", ShimmerTextOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestShimmerTextConstrainedWidth(t *testing.T) {
|
||||
var background ui.Color
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
background = core.Tokens(c).Background
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
ShimmerText(c, "Thinking about the complete program and planning the next steps", ShimmerTextOptions{Active: true}).Width(80)
|
||||
})
|
||||
}, 500, 100)
|
||||
for _, reduce := range []bool{false, true} {
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
|
||||
img := tt.Image()
|
||||
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||
for x := 92; x < img.Bounds().Dx(); x++ {
|
||||
pixel := img.RGBAAt(x, y)
|
||||
if pixel.R != background.R || pixel.G != background.G || pixel.B != background.B {
|
||||
t.Fatalf("text escaped 80 DIP width at %d,%d", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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 loading content; it pulses while painted, static under reduced motion.
|
||||
func Skeleton(c *ui.Context, opts SkeletonOptions) *ui.Element {
|
||||
if opts.Shape != SkeletonText && opts.Shape != SkeletonCircle && opts.Shape != SkeletonRect {
|
||||
panic(fmt.Sprintf("mujica: unknown SkeletonShape %d", int(opts.Shape)))
|
||||
}
|
||||
if !(opts.Width >= 0) || !(opts.Height >= 0) || opts.Lines < 0 || math.IsInf(float64(opts.Width+opts.Height), 1) {
|
||||
panic(fmt.Sprintf("mujica: Skeleton size %vx%v, %d lines invalid", opts.Width, opts.Height, opts.Lines))
|
||||
}
|
||||
k := core.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 = core.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)
|
||||
size := core.FontSize(c, theme.BodySize)
|
||||
line := size * theme.LineHeight
|
||||
e.Height(line * float32(lines)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
bar := size * 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,84 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestSkeletonSizes(t *testing.T) {
|
||||
var text, circle, rect, fixed ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// StaggerOptions configure a Stagger.
|
||||
type StaggerOptions struct {
|
||||
// Interval between items; zero means 60 ms.
|
||||
Interval time.Duration
|
||||
// Duration of each item's entry; zero means PopupDuration.
|
||||
Duration time.Duration
|
||||
// Horizontal lays items in a row.
|
||||
Horizontal bool
|
||||
}
|
||||
|
||||
type staggerState struct {
|
||||
born time.Time
|
||||
done bool
|
||||
}
|
||||
|
||||
// Stagger builds n items entering one after another, once.
|
||||
func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.Element {
|
||||
if n < 0 || opts.Interval < 0 || opts.Duration < 0 {
|
||||
panic("mujica: Stagger needs non-negative n, Interval and Duration")
|
||||
}
|
||||
every, d := opts.Interval, opts.Duration
|
||||
if every == 0 {
|
||||
every = 60 * time.Millisecond
|
||||
}
|
||||
if d == 0 {
|
||||
d = theme.PopupDuration
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if opts.Horizontal {
|
||||
box.Row().AlignItems(ui.Start)
|
||||
}
|
||||
st := ui.Local(box, "stagger", func() *staggerState { return &staggerState{born: c.Now()} })
|
||||
s := *st
|
||||
if n == 0 {
|
||||
return box
|
||||
}
|
||||
upto := n
|
||||
if !s.done && !c.Preferences().ReduceMotion {
|
||||
upto = min(n, 1+int(c.Now().Sub(s.born)/every))
|
||||
if upto < n {
|
||||
box.DrawOver(func(*ui.Painter, ui.Rect) { c.After(s.born.Add(time.Duration(upto) * every).Sub(c.Now())) })
|
||||
} else {
|
||||
s.done = true
|
||||
slog.Debug("mujica: stagger done", "items", n)
|
||||
}
|
||||
}
|
||||
if c.Preferences().ReduceMotion {
|
||||
s.done = true
|
||||
}
|
||||
enter := &ui.Motion{Y: 8}
|
||||
if opts.Horizontal {
|
||||
enter = &ui.Motion{X: 8}
|
||||
}
|
||||
box.Children(func() {
|
||||
for i := range upto {
|
||||
ui.Column(c).Key(i).AlignItems(ui.Stretch).
|
||||
Transition(ui.ElementTransition{Duration: core.Motion(c, d), Enter: enter}).Children(func() { item(i) })
|
||||
}
|
||||
})
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestStaggerRevealsInTurnOnce(t *testing.T) {
|
||||
count := 5
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
ui.Column(c).Children(func() {
|
||||
Stagger(c, count, StaggerOptions{Interval: 50 * time.Millisecond}, func(i int) { ui.Textf(c, "Row %d", i) })
|
||||
})
|
||||
}, 300, 240)
|
||||
if !tt.HasText("Row 0") || tt.HasText("Row 4") {
|
||||
t.Fatalf("first frame: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 2") || tt.HasText("Row 4") {
|
||||
t.Fatalf("after 120 ms: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 4") || motionBusy(tt, n) {
|
||||
t.Fatalf("did not finish and settle: %q", tt.Texts())
|
||||
}
|
||||
count = 6
|
||||
tt.Frame()
|
||||
if !tt.HasText("Row 5") {
|
||||
t.Fatal("item added after the sequence waited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaggerReducedMotionAndInvalid(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if c.Preferences().ReduceMotion {
|
||||
Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Row %d", i) })
|
||||
}
|
||||
}, 300, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if !tt.HasText("Row 2") {
|
||||
t.Fatalf("reduced motion staggered: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative n did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestStaggerSnapshot(t *testing.T) {
|
||||
snapshot(t, "stagger", 300, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Guest %d arrives", i+1) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaggerEmptySettles(t *testing.T) {
|
||||
tt, n := motionFrames(func(c *ui.Context) { Stagger(c, 0, StaggerOptions{}, func(int) { t.Fatal("empty item built") }) }, 200, 80)
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("empty stagger animated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/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 {
|
||||
key := "status_indicator." + status.String() // panics on an unknown Status
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, key)
|
||||
}
|
||||
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 {
|
||||
core.Spinner(c, core.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(core.FontSize(c, theme.BodySize)).SingleLine()
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Locale: core.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) {
|
||||
core.Use(c, core.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) { core.Use(c, core.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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/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 core.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
|
||||
wake time.Time // when this frame asked for the next one; zero for none
|
||||
}
|
||||
|
||||
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 == core.SeverityError:
|
||||
return 0
|
||||
}
|
||||
return ToastDuration
|
||||
}
|
||||
|
||||
func (q *toastQueue) push(m ToastMessage, now time.Time) {
|
||||
if m.Message == "" {
|
||||
panic("mujica: toast needs a Message")
|
||||
}
|
||||
_ = m.Severity.String() // panics on an unknown Severity
|
||||
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 shows queued ones.
|
||||
func (q *toastQueue) expire(now time.Time) {
|
||||
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)
|
||||
}
|
||||
|
||||
// settle pauses the toasts held, resumes the rest, and returns the wait until one expires.
|
||||
func (q *toastQueue) settle(now time.Time, held map[string]bool) (next time.Duration) {
|
||||
for _, it := range q.items {
|
||||
if it.visible {
|
||||
q.pause(it.msg.ID, held[it.msg.ID], 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, never taking the focus; call it last in the view each frame.
|
||||
func Toaster(c *ui.Context) ToasterResult {
|
||||
q := toasts(c)
|
||||
now := c.Now()
|
||||
q.expire(now)
|
||||
held := map[string]bool{}
|
||||
var res ToasterResult
|
||||
if len(q.items) == 0 {
|
||||
return res
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.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(core.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, hold := toastCard(c, k, u, min(360, w-u*8), it.msg)
|
||||
held[it.msg.ID] = hold
|
||||
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()
|
||||
}
|
||||
}
|
||||
// Scheduled after resuming, so a toast released this frame still expires.
|
||||
if next := q.settle(now, held); next > 0 {
|
||||
res.wake = now.Add(next)
|
||||
c.After(next)
|
||||
}
|
||||
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 := core.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 != "" && input.Button(c, m.Action, input.ButtonOptions{Variant: input.Ghost}).Clicked() {
|
||||
action = true
|
||||
}
|
||||
x := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("x"), Label: core.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()
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
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.
|
||||
q.expire(t0.Add(ToastDuration))
|
||||
if next := q.settle(t0.Add(ToastDuration), nil); 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: core.SeverityError, Message: "Failed"}, t0)
|
||||
q.push(ToastMessage{ID: "w", Severity: core.SeverityWarning, Message: "Slow", Action: "Details"}, t0)
|
||||
q.settle(t0.Add(4*time.Second), map[string]bool{"w": true})
|
||||
q.expire(t0.Add(time.Hour))
|
||||
if next := q.settle(t0.Add(time.Hour), map[string]bool{"w": true}); 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))
|
||||
}
|
||||
// Released: the wake-up is computed after resuming, in the same call.
|
||||
if next := q.settle(t0.Add(time.Hour), nil); 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}, {Message: "x", Severity: 9}} {
|
||||
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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Notify", input.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
|
||||
const life = 40 * time.Millisecond
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Notify", input.ButtonOptions{}).Clicked() {
|
||||
ShowToast(c, ToastMessage{Message: "Copied", Duration: life})
|
||||
}
|
||||
res = Toaster(c)
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Notify")
|
||||
if res.wake.IsZero() {
|
||||
t.Fatal("no wake-up scheduled for a running toast")
|
||||
}
|
||||
r, _ := tt.Find("Copied")
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
if !res.wake.IsZero() {
|
||||
t.Fatal("hovered toast still schedules its expiry")
|
||||
}
|
||||
time.Sleep(2 * life)
|
||||
released := time.Now()
|
||||
tt.Move(5, 395)
|
||||
done := time.Now()
|
||||
// The frame that ends the hover must schedule the expiry itself.
|
||||
if res.wake.IsZero() || res.wake.Before(released) || res.wake.After(done.Add(life)) {
|
||||
t.Fatalf("release scheduled wake %v, released %v", res.wake, released)
|
||||
}
|
||||
if !tt.HasText("Copied") {
|
||||
t.Fatal("hovered toast expired")
|
||||
}
|
||||
// Stands in for the frame MyGo's timer runs at res.wake.
|
||||
time.Sleep(time.Until(res.wake) + 5*time.Millisecond)
|
||||
tt.Frame()
|
||||
if tt.HasText("Copied") {
|
||||
t.Fatal("toast stayed after its wake-up")
|
||||
}
|
||||
}
|
||||
|
||||
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: core.SeveritySuccess})
|
||||
ShowToast(c, ToastMessage{ID: "2", Message: "Note moved to trash", Action: "Undo"})
|
||||
ShowToast(c, ToastMessage{ID: "3", Message: "Upload failed: network lost", Severity: core.SeverityError})
|
||||
ShowToast(c, ToastMessage{ID: "4", Message: "Queued and not shown"})
|
||||
}
|
||||
Toaster(c)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// TokenUsage is the tokens one session or step used.
|
||||
type TokenUsage struct {
|
||||
Label string
|
||||
Input, Output int
|
||||
}
|
||||
|
||||
// TokenUsageChartOptions configure a TokenUsageChart.
|
||||
type TokenUsageChartOptions struct {
|
||||
// Frame names, sizes and states the chart.
|
||||
Frame chart.ChartFrame
|
||||
// Line draws lines instead of stacked bars.
|
||||
Line bool
|
||||
// Version changes when usage is edited in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
type tokenUsageSeries struct {
|
||||
src *TokenUsage
|
||||
n int
|
||||
version int
|
||||
labels []string
|
||||
series []chart.ChartSeries
|
||||
}
|
||||
|
||||
// TokenUsageChart shows input and output tokens per step.
|
||||
func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) chart.ChartResult {
|
||||
if opts.Frame.Label == "" {
|
||||
opts.Frame.Label = core.Msg(c, "token_usage_chart.label")
|
||||
}
|
||||
holder := ui.Column(c).AlignItems(ui.Stretch)
|
||||
cache := ui.Local(holder, "series", func() *tokenUsageSeries { return &tokenUsageSeries{} })
|
||||
s := *cache
|
||||
var first *TokenUsage
|
||||
if len(usage) > 0 {
|
||||
first = &usage[0]
|
||||
}
|
||||
if s.series == nil || s.src != first || s.n != len(usage) || s.version != opts.Version {
|
||||
step := max(1, (len(usage)+255)/256)
|
||||
n := (len(usage) + step - 1) / step
|
||||
in, out := make([]float64, n), make([]float64, n)
|
||||
s.labels = make([]string, n)
|
||||
for i, u := range usage {
|
||||
if u.Input < 0 || u.Output < 0 {
|
||||
panic("mujica: TokenUsage counts must not be negative")
|
||||
}
|
||||
at := i / step
|
||||
in[at] += float64(u.Input)
|
||||
out[at] += float64(u.Output)
|
||||
if i%step == 0 {
|
||||
s.labels[at] = u.Label
|
||||
}
|
||||
if step > 1 && (i%step == step-1 || i == len(usage)-1) {
|
||||
s.labels[at] += " … " + u.Label
|
||||
}
|
||||
}
|
||||
s.series = []chart.ChartSeries{{Name: core.Msg(c, "token_usage_chart.input"), Values: in}, {Name: core.Msg(c, "token_usage_chart.output"), Values: out}}
|
||||
s.src, s.n, s.version = first, len(usage), opts.Version
|
||||
}
|
||||
s.series[0].Name = core.Msg(c, "token_usage_chart.input")
|
||||
s.series[1].Name = core.Msg(c, "token_usage_chart.output")
|
||||
format := func(v float64) string { return strconv.FormatFloat(v, 'f', 0, 64) }
|
||||
var res chart.ChartResult
|
||||
holder.Children(func() {
|
||||
if opts.Line {
|
||||
res = chart.LineChart(c, s.series, chart.LineChartOptions{Frame: opts.Frame, Format: format})
|
||||
return
|
||||
}
|
||||
res = chart.BarChart(c, s.labels, s.series, chart.BarChartOptions{Frame: opts.Frame, Stacked: true, Format: format})
|
||||
})
|
||||
res.Element = holder
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func tokenUsageSample() []TokenUsage {
|
||||
return []TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Search", Input: 4800, Output: 650}, {Label: "Write", Input: 2100, Output: 2900}}
|
||||
}
|
||||
|
||||
func TestTokenUsageChartBarsKeyboard(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
h := &chartHarness{build: func(c *ui.Context) chart.ChartResult { return TokenUsageChart(c, usage, TokenUsageChartOptions{}) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Token usage")
|
||||
want := []string{"Plan · Input 1200", "Plan · Output 300", "Search · Input 4800"}
|
||||
for i, w := range want {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != w {
|
||||
t.Fatalf("mark %d readout %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
if !tt.HasText("Input") || !tt.HasText("Output") {
|
||||
t.Fatal("legend missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsageChartLineEmptyInvalid(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
h := &chartHarness{build: func(c *ui.Context) chart.ChartResult {
|
||||
return TokenUsageChart(c, usage, TokenUsageChartOptions{Line: true, Frame: chart.ChartFrame{Label: "Tokens per step"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tokens per step")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.res.Point < 0 {
|
||||
t.Fatal("line chart has no keyboard path")
|
||||
}
|
||||
usage = nil
|
||||
tt.Frame()
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative tokens did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TokenUsageChart(c, []TokenUsage{{Label: "x", Input: -1}}, TokenUsageChartOptions{})
|
||||
}, 300, 200, 1)
|
||||
}
|
||||
|
||||
func TestTokenUsageChartSnapshot(t *testing.T) {
|
||||
usage := tokenUsageSample()
|
||||
snapshot(t, "token_usage_chart", 520, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
TokenUsageChart(c, usage, TokenUsageChartOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenUsageChartLocaleRefresh(t *testing.T) {
|
||||
locale := core.EnUS
|
||||
usage := tokenUsageSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: locale})
|
||||
TokenUsageChart(c, usage, TokenUsageChartOptions{})
|
||||
}, 520, 420)
|
||||
locale = core.ZhCN
|
||||
tt.Frame()
|
||||
if !tt.HasText("输入") || !tt.HasText("输出") {
|
||||
t.Fatalf("stale legend: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsageChartLargeDataBounded(t *testing.T) {
|
||||
usage := make([]TokenUsage, 100000)
|
||||
for i := range usage {
|
||||
usage[i] = TokenUsage{Label: strconv.Itoa(i), Input: 1, Output: 2}
|
||||
}
|
||||
var cache *tokenUsageSeries
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := TokenUsageChart(c, usage, TokenUsageChartOptions{})
|
||||
cache = *ui.Local(r.Element, "series", func() *tokenUsageSeries { panic("missing series cache") })
|
||||
}, 520, 420)
|
||||
if len(cache.labels) > 256 {
|
||||
t.Fatalf("built %d categories", len(cache.labels))
|
||||
}
|
||||
in, out := 0.0, 0.0
|
||||
for i := range cache.labels {
|
||||
in += cache.series[0].Values[i]
|
||||
out += cache.series[1].Values[i]
|
||||
}
|
||||
if in != 100000 || out != 200000 {
|
||||
t.Fatalf("aggregated totals %v %v", in, out)
|
||||
}
|
||||
first := &cache.series[0].Values[0]
|
||||
tt.Frame()
|
||||
if &cache.series[0].Values[0] != first {
|
||||
t.Fatal("unchanged data reaggregated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ToolParam is a parameter in a tool's input schema.
|
||||
type ToolParam struct {
|
||||
Name string
|
||||
Type string
|
||||
Required bool
|
||||
Description string
|
||||
}
|
||||
|
||||
// AgentTool is a callable tool; the caller keeps Enabled.
|
||||
type AgentTool struct {
|
||||
Name string
|
||||
Description string
|
||||
Enabled bool
|
||||
Params []ToolParam
|
||||
}
|
||||
|
||||
// ToolRegistryPanelOptions configure a ToolRegistryPanel.
|
||||
type ToolRegistryPanelOptions struct {
|
||||
Disabled bool
|
||||
// Version changes when tool names are edited in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// ToolRegistryResult is a built ToolRegistryPanel and its events.
|
||||
type ToolRegistryResult struct {
|
||||
Element *ui.Element
|
||||
List data.ListView
|
||||
toggled string
|
||||
on bool
|
||||
}
|
||||
|
||||
// Toggled returns the tool switched this frame.
|
||||
func (r ToolRegistryResult) Toggled() (name string, on, ok bool) {
|
||||
return r.toggled, r.on, r.toggled != ""
|
||||
}
|
||||
|
||||
type toolRegistryIndex struct {
|
||||
src *AgentTool
|
||||
n, version int
|
||||
byName map[string]int
|
||||
}
|
||||
|
||||
// ToolRegistryPanel lists tools with switches and a schema preview.
|
||||
func ToolRegistryPanel(c *ui.Context, s *data.ListState[string], tools []AgentTool, opts ToolRegistryPanelOptions) ToolRegistryResult {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Stretch).Gap(u * 3).Disabled(opts.Disabled)
|
||||
res := ToolRegistryResult{Element: row}
|
||||
index := ui.Local(row, "index", func() toolRegistryIndex { return toolRegistryIndex{} })
|
||||
var first *AgentTool
|
||||
if len(tools) > 0 {
|
||||
first = &tools[0]
|
||||
}
|
||||
if index.byName == nil || index.src != first || index.n != len(tools) || index.version != opts.Version {
|
||||
byName := make(map[string]int, len(tools))
|
||||
for i, t := range tools {
|
||||
if _, exists := byName[t.Name]; t.Name == "" || exists {
|
||||
panic("mujica: AgentTool needs a unique Name")
|
||||
}
|
||||
byName[t.Name] = i
|
||||
}
|
||||
*index = toolRegistryIndex{src: first, n: len(tools), version: opts.Version, byName: byName}
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Column(c).Width(300).Shrink(0).AlignItems(ui.Stretch).Children(func() {
|
||||
res.List = data.List(c, s, len(tools), data.ListOptions[string]{
|
||||
Key: func(i int) string { return tools[i].Name }, Label: func(i int) string { return tools[i].Name },
|
||||
Empty: core.Msg(c, "tool_registry_panel.empty"),
|
||||
}, func(i int) {
|
||||
t := tools[i]
|
||||
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
||||
ui.Text(c, t.Name).Font(theme.MonoFont).FontWeight(600).SingleLine()
|
||||
ui.Text(c, t.Description).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
on := t.Enabled
|
||||
input.Switch(c, &on, "", input.SwitchOptions{}).Label(fmt.Sprintf(core.Msg(c, "tool_registry_panel.enable"), t.Name))
|
||||
if on != t.Enabled && !row.IsDisabled() {
|
||||
res.toggled, res.on = t.Name, on
|
||||
slog.Debug("mujica: tool registry toggle", "tool", t.Name, "on", on)
|
||||
}
|
||||
})
|
||||
res.List.Element.Grow(1)
|
||||
})
|
||||
ui.Column(c).Grow(1).MinWidth(0).Gap(u*2).Padding(u*3, u*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Children(func() {
|
||||
name, ok := s.Selected()
|
||||
i, known := index.byName[name]
|
||||
if !ok || !known {
|
||||
ui.Text(c, core.Msg(c, "tool_registry_panel.choose")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
toolSchema(c, tools[i])
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// toolSchema previews a tool's parameters as a typed list.
|
||||
func toolSchema(c *ui.Context, t AgentTool) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
small := core.FontSize(c, theme.CaptionSize)
|
||||
ui.Text(c, t.Name).Font(theme.SerifFont).FontWeight(600).FontSize(core.FontSize(c, theme.H3Size))
|
||||
if t.Description != "" {
|
||||
ui.Text(c, t.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
if len(t.Params) == 0 {
|
||||
ui.Text(c, core.Msg(c, "tool_registry_panel.no_params")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
ui.Column(c).Key(t.Name).Grow(1).MinHeight(0).Children(func() {
|
||||
ui.List(c, nil, len(t.Params), func(i int) {
|
||||
p := t.Params[i]
|
||||
ui.Column(c).Gap(2).MinHeight(core.Density(c).ControlHeight()).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, p.Name).Font(theme.MonoFont).FontWeight(600)
|
||||
ui.Text(c, p.Type).Font(theme.MonoFont).FontSize(small).TextColor(k.Info)
|
||||
if p.Required {
|
||||
ui.Text(c, core.Msg(c, "tool_registry_panel.required")).FontSize(small).TextColor(k.AccentText).
|
||||
Padding(0, u).Border(theme.BorderWidth, k.AccentText).Radius(theme.ControlRadius)
|
||||
}
|
||||
})
|
||||
if p.Description != "" {
|
||||
ui.Text(c, p.Description).FontSize(small).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
}).Grow(1).MinHeight(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func toolSample() []AgentTool {
|
||||
return []AgentTool{
|
||||
{Name: "web_search", Description: "Search the web", Enabled: true, Params: []ToolParam{
|
||||
{Name: "query", Type: "string", Required: true, Description: "What to look for"},
|
||||
{Name: "limit", Type: "integer"},
|
||||
}},
|
||||
{Name: "read_file", Description: "Read a local file"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelToggleAndPreview(t *testing.T) {
|
||||
var s data.ListState[string]
|
||||
tools := toolSample()
|
||||
disabled := false
|
||||
var toggled []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(400).Children(func() {
|
||||
r := ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{Disabled: disabled})
|
||||
r.Element.Grow(1)
|
||||
if name, on, ok := r.Toggled(); ok {
|
||||
toggled = append(toggled, name)
|
||||
for i := range tools {
|
||||
if tools[i].Name == name {
|
||||
tools[i].Enabled = on
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 800, 420)
|
||||
if !tt.HasText("Choose a tool to see its parameters") {
|
||||
t.Fatalf("empty preview: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Search the web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("query") || !tt.HasText("required") || !tt.HasText("integer") {
|
||||
t.Fatalf("schema: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("read_file"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("No parameters") {
|
||||
t.Fatalf("no params: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Enable read_file"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(toggled) != 1 || toggled[0] != "read_file" || !tools[1].Enabled {
|
||||
t.Fatalf("toggle %q enabled %v", toggled, tools[1].Enabled)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Enable read_file")
|
||||
if len(toggled) != 1 {
|
||||
t.Fatal("disabled panel toggled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelInvalid(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
var s data.ListState[string]
|
||||
ToolRegistryPanel(c, &s, []AgentTool{{Name: "a"}, {Name: "a"}}, ToolRegistryPanelOptions{})
|
||||
}, 300, 200, 1)
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelSnapshot(t *testing.T) {
|
||||
tools := toolSample()
|
||||
snapshotAfter(t, "tool_registry_panel", 760, 300, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *data.ListState[string] { return &data.ListState[string]{} })
|
||||
ui.Column(c).Padding(16).Height(300).Children(func() {
|
||||
ToolRegistryPanel(c, *s, tools, ToolRegistryPanelOptions{}).Element.Grow(1)
|
||||
})
|
||||
}, func(tt *ui.Tester) { _ = tt.Click("Search the web") })
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelLargeDataKeyboardAndAncestorDisabled(t *testing.T) {
|
||||
tools := make([]AgentTool, 100000)
|
||||
for i := range tools {
|
||||
tools[i] = AgentTool{Name: strconv.Itoa(i)}
|
||||
}
|
||||
var s data.ListState[string]
|
||||
disabled := false
|
||||
toggles := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r := ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{})
|
||||
r.Element.Height(300)
|
||||
if _, _, ok := r.Toggled(); ok {
|
||||
toggles++
|
||||
}
|
||||
})
|
||||
}, 700, 400)
|
||||
a, b := s.Rows.Visible()
|
||||
if b-a > 30 || len(tt.Texts()) > 150 {
|
||||
t.Fatalf("built %d rows", b-a)
|
||||
}
|
||||
_ = tt.Click("0")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if id, _ := s.Selected(); id != "1" {
|
||||
t.Fatalf("keyboard selected %q", id)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if toggles != 1 {
|
||||
t.Fatalf("keyboard toggles %d", toggles)
|
||||
}
|
||||
disabled = true
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if toggles != 1 {
|
||||
t.Fatal("ancestor disabled toggled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistryPanelLargeParameterPreview(t *testing.T) {
|
||||
tools := []AgentTool{{Name: "Tool", Params: make([]ToolParam, 100000)}}
|
||||
for i := range tools[0].Params {
|
||||
tools[0].Params[i] = ToolParam{Name: "Param " + strconv.Itoa(i), Type: "string"}
|
||||
}
|
||||
var s data.ListState[string]
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{}).Element.Height(300)
|
||||
}, 700, 400)
|
||||
if err := tt.Click("Tool"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Param 0") || len(tt.Texts()) > 150 {
|
||||
t.Fatalf("parameter preview built %d texts", len(tt.Texts()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TraceAttr is a key and value recorded on a span.
|
||||
type TraceAttr struct{ Key, Value string }
|
||||
|
||||
// TraceSpan records one call with trace-relative time offsets.
|
||||
type TraceSpan struct {
|
||||
ID string
|
||||
Parent string
|
||||
Name string
|
||||
Start time.Duration
|
||||
End time.Duration
|
||||
Failed bool
|
||||
Attrs []TraceAttr
|
||||
}
|
||||
|
||||
// TraceViewerState holds the tree's sort, open spans, selection and index.
|
||||
type TraceViewerState struct {
|
||||
Tree data.TreeTableState[string]
|
||||
index traceIndex
|
||||
src *TraceSpan
|
||||
n int
|
||||
built bool
|
||||
version int
|
||||
}
|
||||
|
||||
type traceIndex struct {
|
||||
byID map[string]int
|
||||
kids map[string][]string
|
||||
roots []string
|
||||
end time.Duration
|
||||
}
|
||||
|
||||
// TraceViewerOptions configure a TraceViewer.
|
||||
type TraceViewerOptions struct {
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
// Version changes when spans are edited in place.
|
||||
Version int
|
||||
}
|
||||
|
||||
// TraceViewer shows a span tree with time bars and attributes.
|
||||
func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts TraceViewerOptions) data.DataTableView {
|
||||
var first *TraceSpan
|
||||
if len(spans) > 0 {
|
||||
first = &spans[0]
|
||||
}
|
||||
if !s.built || s.src != first || s.n != len(spans) || s.version != opts.Version {
|
||||
previous := s.index.byID
|
||||
s.index = traceBuild(spans)
|
||||
s.src, s.n, s.built = first, len(spans), true
|
||||
s.version = opts.Version
|
||||
for _, id := range s.index.roots {
|
||||
if _, known := previous[id]; !known {
|
||||
s.Tree.SetOpen(id, true)
|
||||
}
|
||||
}
|
||||
slog.Debug("mujica: trace viewer index", "spans", len(spans), "roots", len(s.index.roots))
|
||||
}
|
||||
ix := s.index
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
span := func(id string) TraceSpan { return spans[ix.byID[id]] }
|
||||
cols := []data.TreeColumn[string]{
|
||||
{ID: "name", Title: core.Msg(c, "trace_viewer.span"), Compare: func(a, b string) int { return cmp.Compare(span(a).Name, span(b).Name) },
|
||||
Cell: func(id string) {
|
||||
sp := span(id)
|
||||
if sp.Failed {
|
||||
ui.Icon(c, icons.Must("circle-x")).Size(14, 14).TextColor(k.Danger).Shrink(0).Margin(0, u, 0, 0)
|
||||
}
|
||||
t := ui.Text(c, sp.Name).SingleLine()
|
||||
if sp.Failed {
|
||||
t.TextColor(k.Danger).Label(sp.Name + ", " + core.Msg(c, "trace_viewer.failed"))
|
||||
}
|
||||
}},
|
||||
{ID: "duration", Title: core.Msg(c, "trace_viewer.duration"), Width: 96, Align: ui.End,
|
||||
Compare: func(a, b string) int { return cmp.Compare(span(a).End-span(a).Start, span(b).End-span(b).Start) },
|
||||
Cell: func(id string) {
|
||||
sp := span(id)
|
||||
ui.Text(c, traceDuration(sp.End-sp.Start)).FontFeatures("tnum").TextColor(k.TextMuted)
|
||||
}},
|
||||
{ID: "timeline", Title: core.Msg(c, "trace_viewer.timeline"), Width: 260, Compare: func(a, b string) int { return cmp.Compare(span(a).Start, span(b).Start) },
|
||||
Cell: func(id string) {
|
||||
sp := span(id)
|
||||
col := k.AccentText
|
||||
if sp.Failed {
|
||||
col = k.Danger
|
||||
}
|
||||
ui.Box(c).Height(12).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H/2, W: r.W, H: 1}, k.Border, 0)
|
||||
if ix.end <= 0 {
|
||||
return
|
||||
}
|
||||
x0 := r.X + r.W*float32(float64(sp.Start)/float64(ix.end))
|
||||
w := max(2, r.W*float32(float64(sp.End-sp.Start)/float64(ix.end)))
|
||||
bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: internal.Snap(p, w), H: r.H}
|
||||
if sp.Failed {
|
||||
// Failed spans are hollow, not told apart by color alone.
|
||||
p.Fill(bar, k.Surface, 2)
|
||||
p.Stroke(bar, col, 2, 1.5)
|
||||
return
|
||||
}
|
||||
p.Fill(bar, col, 2)
|
||||
})
|
||||
}},
|
||||
}
|
||||
var v data.DataTableView
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
bounds := col.Bounds()
|
||||
internal.Remeasure(c, col, bounds.W, bounds.H)
|
||||
col.Children(func() {
|
||||
v = data.TreeTable(c, &s.Tree, data.TreeTableOptions[string]{Columns: cols, Roots: ix.roots,
|
||||
Children: func(id string) []string { return ix.kids[id] },
|
||||
Label: func(id string) string { return span(id).Name },
|
||||
Status: opts.Status, Error: opts.Error, Empty: core.Msg(c, "trace_viewer.empty")})
|
||||
v.Element.Grow(1)
|
||||
id, ok := s.Tree.Selected()
|
||||
if _, known := ix.byID[id]; !ok || !known {
|
||||
return
|
||||
}
|
||||
height := bounds.H
|
||||
if height == 0 {
|
||||
_, height = c.Size()
|
||||
}
|
||||
traceAttrs(c, span(id), height)
|
||||
})
|
||||
v.Element = col
|
||||
return v
|
||||
}
|
||||
|
||||
// traceAttrs lists the chosen span's timing and attributes.
|
||||
func traceAttrs(c *ui.Context, sp TraceSpan, height float32) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ui.Column(c).Gap(u).Padding(u*3, u*4).Background(k.Background).Radius(theme.CardRadius).Shrink(0).Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() {
|
||||
ui.Text(c, sp.Name).Font(theme.SerifFont).FontWeight(600).SingleLine().Grow(1).MinWidth(0)
|
||||
ui.Text(c, core.Msg(c, "trace_viewer.start")+" "+traceDuration(sp.Start)+" · "+core.Msg(c, "trace_viewer.duration")+" "+traceDuration(sp.End-sp.Start)).
|
||||
FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
row := func(key, val string) {
|
||||
ui.Row(c).Gap(u * 3).MinHeight(core.Density(c).ControlHeight()).Children(func() {
|
||||
ui.Text(c, key).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Width(140).Shrink(0)
|
||||
ui.Text(c, val).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize)).Grow(1).MinWidth(0)
|
||||
})
|
||||
}
|
||||
if len(sp.Attrs) > 0 {
|
||||
ui.Column(c).Key(sp.ID).Height(min(float32(len(sp.Attrs))*core.Density(c).ControlHeight(), max(core.Density(c).ControlHeight(), min(200, height/4)))).Children(func() {
|
||||
ui.List(c, nil, len(sp.Attrs), func(i int) {
|
||||
a := sp.Attrs[i]
|
||||
row(a.Key, a.Value)
|
||||
}).Grow(1).MinHeight(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// traceBuild indexes spans; unknown parents make roots.
|
||||
func traceBuild(spans []TraceSpan) traceIndex {
|
||||
ix := traceIndex{byID: make(map[string]int, len(spans)), kids: map[string][]string{}}
|
||||
for i, sp := range spans {
|
||||
if sp.ID == "" || sp.End < sp.Start || sp.Start < 0 {
|
||||
panic(fmt.Sprintf("mujica: TraceSpan %q needs an ID and 0 <= Start <= End", sp.ID))
|
||||
}
|
||||
if _, dup := ix.byID[sp.ID]; dup {
|
||||
panic("mujica: duplicate TraceSpan ID " + sp.ID)
|
||||
}
|
||||
ix.byID[sp.ID] = i
|
||||
ix.end = max(ix.end, sp.End)
|
||||
}
|
||||
for _, sp := range spans {
|
||||
if _, ok := ix.byID[sp.Parent]; ok {
|
||||
ix.kids[sp.Parent] = append(ix.kids[sp.Parent], sp.ID)
|
||||
} else {
|
||||
ix.roots = append(ix.roots, sp.ID)
|
||||
}
|
||||
}
|
||||
seen := 0
|
||||
var walk func(ids []string)
|
||||
walk = func(ids []string) {
|
||||
for _, id := range ids {
|
||||
seen++
|
||||
walk(ix.kids[id])
|
||||
}
|
||||
}
|
||||
walk(ix.roots)
|
||||
if seen != len(spans) {
|
||||
panic("mujica: TraceSpan parents form a cycle")
|
||||
}
|
||||
return ix
|
||||
}
|
||||
|
||||
// traceDuration prints microseconds, milliseconds or seconds.
|
||||
func traceDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Millisecond:
|
||||
return fmt.Sprintf("%d µs", d.Microseconds())
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%.1f ms", float64(d)/float64(time.Millisecond))
|
||||
}
|
||||
return fmt.Sprintf("%.2f s", d.Seconds())
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func traceSample() []TraceSpan {
|
||||
ms := time.Millisecond
|
||||
return []TraceSpan{
|
||||
{ID: "root", Name: "agent.run", Start: 0, End: 900 * ms, Attrs: []TraceAttr{{"model", "opus"}}},
|
||||
{ID: "plan", Parent: "root", Name: "llm.plan", Start: 10 * ms, End: 210 * ms, Attrs: []TraceAttr{{"tokens", "1200"}}},
|
||||
{ID: "search", Parent: "root", Name: "tool.search", Start: 220 * ms, End: 520 * ms, Failed: true},
|
||||
{ID: "fetch", Parent: "search", Name: "http.get", Start: 230 * ms, End: 500 * ms},
|
||||
{ID: "write", Parent: "root", Name: "llm.write", Start: 530 * ms, End: 880 * ms},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceBuild(t *testing.T) {
|
||||
ix := traceBuild(traceSample())
|
||||
if len(ix.roots) != 1 || ix.roots[0] != "root" || len(ix.kids["root"]) != 3 || ix.end != 900*time.Millisecond {
|
||||
t.Fatalf("index %+v", ix)
|
||||
}
|
||||
for name, spans := range map[string][]TraceSpan{
|
||||
"dup": {{ID: "a"}, {ID: "a"}},
|
||||
"time": {{ID: "a", Start: 2, End: 1}},
|
||||
"cycle": {{ID: "a", Parent: "b"}, {ID: "b", Parent: "a"}},
|
||||
"self": {{ID: "a", Parent: "a"}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s did not panic", name)
|
||||
}
|
||||
}()
|
||||
traceBuild(spans)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerSelectShowsAttributes(t *testing.T) {
|
||||
var s TraceViewerState
|
||||
spans := traceSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Height(600).Children(func() {
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Grow(1)
|
||||
})
|
||||
}, 900, 620)
|
||||
if !tt.HasText("llm.plan") || !tt.HasText("200.0 ms") {
|
||||
t.Fatalf("tree: %q", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("tool.search, failed"); !ok {
|
||||
t.Fatal("failed span not named")
|
||||
}
|
||||
if tt.HasText("http.get") {
|
||||
t.Fatal("nested span shown before opening its parent")
|
||||
}
|
||||
if err := tt.Click("llm.plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, _ := s.Tree.Selected(); id != "plan" || !tt.HasText("tokens") || !tt.HasText("1200") {
|
||||
t.Fatalf("attributes: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if id, _ := s.Tree.Selected(); id != "search" || !tt.HasText("http.get") {
|
||||
t.Fatalf("keyboard open: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerSnapshot(t *testing.T) {
|
||||
spans := traceSample()
|
||||
snapshot(t, "trace_viewer", 820, 420, func(c *ui.Context) {
|
||||
s := ui.Local(c.Root(), "s", func() *TraceViewerState {
|
||||
st := &TraceViewerState{}
|
||||
st.Tree.SetOpen("search", true)
|
||||
return st
|
||||
})
|
||||
ui.Column(c).Padding(16).Height(420).Children(func() {
|
||||
TraceViewer(c, *s, spans, TraceViewerOptions{}).Element.Grow(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTraceViewerRefreshKeepsCollapsedRoot(t *testing.T) {
|
||||
var s TraceViewerState
|
||||
spans := traceSample()
|
||||
tt, _ := motionFrames(func(c *ui.Context) {
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(300)
|
||||
}, 700, 400)
|
||||
s.Tree.SetOpen("root", false)
|
||||
tt.Frame()
|
||||
spans = append([]TraceSpan(nil), spans...)
|
||||
tt.Frame()
|
||||
if s.Tree.IsOpen("root") || tt.HasText("llm.plan") {
|
||||
t.Fatal("data refresh reopened a collapsed root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerLargeDataAndVersion(t *testing.T) {
|
||||
spans := make([]TraceSpan, 100000)
|
||||
for i := range spans {
|
||||
spans[i] = TraceSpan{ID: strconv.Itoa(i), Name: "Span " + strconv.Itoa(i), End: time.Millisecond}
|
||||
}
|
||||
var s TraceViewerState
|
||||
version := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{Version: version}).Element.Height(300)
|
||||
}, 700, 400)
|
||||
if len(s.index.byID) != 100000 || len(tt.Texts()) > 150 {
|
||||
t.Fatalf("indexed %d texts %d", len(s.index.byID), len(tt.Texts()))
|
||||
}
|
||||
spans[0].End = 2 * time.Millisecond
|
||||
version++
|
||||
tt.Frame()
|
||||
if !tt.HasText("2.0 ms") {
|
||||
t.Fatal("stale duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerLargeAttributePreview(t *testing.T) {
|
||||
spans := []TraceSpan{{ID: "root", Name: "Root", Attrs: make([]TraceAttr, 100000)}}
|
||||
for i := range spans[0].Attrs {
|
||||
spans[0].Attrs[i] = TraceAttr{Key: "Key " + strconv.Itoa(i), Value: "Value"}
|
||||
}
|
||||
var s TraceViewerState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(500)
|
||||
}, 700, 600)
|
||||
if err := tt.Click("Root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Key 0") || len(tt.Texts()) > 150 {
|
||||
t.Fatalf("attribute preview built %d texts", len(tt.Texts()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceViewerAttributesKeepTreeVisible(t *testing.T) {
|
||||
spans := []TraceSpan{{ID: "a", Name: "First span", Attrs: make([]TraceAttr, 20)}, {ID: "b", Name: "Second span"}}
|
||||
for i := range spans[0].Attrs {
|
||||
spans[0].Attrs[i] = TraceAttr{Key: strconv.Itoa(i), Value: "Value"}
|
||||
}
|
||||
var s TraceViewerState
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(260)
|
||||
}, 700, 600)
|
||||
if err := tt.Click("First span"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bounds, ok := tt.Find("Second span")
|
||||
if !ok || bounds.H <= 0 || bounds.W <= 0 {
|
||||
t.Fatalf("attributes hid tree row: %+v", bounds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// TypewriterOptions configure a Typewriter.
|
||||
type TypewriterOptions struct {
|
||||
// Speed is characters per second; zero means 40.
|
||||
Speed float64
|
||||
// Skippable adds a Skip button.
|
||||
Skippable bool
|
||||
}
|
||||
|
||||
type typewriterState struct {
|
||||
text []rune
|
||||
shown int
|
||||
last time.Time
|
||||
skipped bool
|
||||
done bool
|
||||
}
|
||||
|
||||
// Typewriter reveals text one character at a time.
|
||||
func Typewriter(c *ui.Context, text string, opts TypewriterOptions) core.ChoiceResult {
|
||||
speed := opts.Speed
|
||||
if speed == 0 {
|
||||
speed = 40
|
||||
}
|
||||
if math.IsNaN(speed) || math.IsInf(speed, 0) || speed < float64(time.Second)/float64(math.MaxInt64) || speed > float64(time.Second) {
|
||||
panic("mujica: Typewriter Speed must give a finite positive interval")
|
||||
}
|
||||
step := time.Duration(float64(time.Second) / speed)
|
||||
if step <= 0 {
|
||||
panic("mujica: Typewriter Speed interval exceeds time.Duration")
|
||||
}
|
||||
col := ui.Column(c).AlignItems(ui.Start).Gap(core.Density(c).Unit() * 2).Role(ui.RoleGroup).Label(text)
|
||||
res := core.ChoiceResult{Element: col}
|
||||
st := ui.Local(col, "typing", func() *typewriterState { return &typewriterState{last: c.Now()} })
|
||||
s := *st
|
||||
runes := []rune(text)
|
||||
if !strings.HasPrefix(text, string(s.text)) {
|
||||
s.shown, s.skipped, s.done = 0, false, false
|
||||
s.last = c.Now()
|
||||
slog.Debug("mujica: typewriter restart")
|
||||
}
|
||||
if len(runes) > len(s.text) && s.done {
|
||||
// Appended text continues at the typing speed.
|
||||
s.done, s.skipped, s.last = false, false, c.Now()
|
||||
}
|
||||
s.text = runes
|
||||
now := c.Now()
|
||||
if c.Preferences().ReduceMotion || s.skipped {
|
||||
s.shown = len(runes)
|
||||
} else if s.shown < len(runes) {
|
||||
if n := int(now.Sub(s.last) / step); n > 0 {
|
||||
s.shown = min(len(runes), s.shown+n)
|
||||
s.last = s.last.Add(time.Duration(n) * step)
|
||||
}
|
||||
if s.shown < len(runes) {
|
||||
col.DrawOver(func(*ui.Painter, ui.Rect) { c.After(s.last.Add(step).Sub(c.Now())) })
|
||||
}
|
||||
} else {
|
||||
s.last = now
|
||||
}
|
||||
col.Children(func() {
|
||||
ui.Text(c, string(runes[:s.shown]))
|
||||
if opts.Skippable && s.shown < len(runes) {
|
||||
b := input.Button(c, core.Msg(c, "typewriter.skip"), input.ButtonOptions{Variant: input.Ghost})
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
s.skipped, s.shown = true, len(runes)
|
||||
slog.Debug("mujica: typewriter skip")
|
||||
}
|
||||
}
|
||||
})
|
||||
if s.shown == len(runes) && !s.done {
|
||||
s.done, res = true, core.NewChoiceResult(res.Element, true)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestTypewriterRevealsFinishesAndSkips(t *testing.T) {
|
||||
text := "The court assembles at dusk."
|
||||
finished := 0
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
if Typewriter(c, text, TypewriterOptions{Speed: 100, Skippable: true}).Changed() {
|
||||
finished++
|
||||
}
|
||||
}, 400, 120)
|
||||
if tt.HasText(text) && motionCount(tt, text) == 2 {
|
||||
t.Fatal("whole text shown at once")
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
shown := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s != text && s != "Skip" && len(s) > shown {
|
||||
shown = len(s)
|
||||
}
|
||||
}
|
||||
if shown < 5 || shown >= len(text) {
|
||||
t.Fatalf("partial reveal %d of %d: %q", shown, len(text), tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Skip"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if motionCount(tt, text) != 2 || finished != 1 || tt.HasText("Skip") || motionBusy(tt, n) {
|
||||
t.Fatalf("skip: finished %d texts %q", finished, tt.Texts())
|
||||
}
|
||||
text += " The music begins."
|
||||
tt.Frame()
|
||||
if motionCount(tt, "The court assembles at dusk.") != 1 {
|
||||
t.Fatalf("appended text restarted: %q", tt.Texts())
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if motionCount(tt, text) != 2 || finished != 2 {
|
||||
t.Fatalf("append: finished %d %q", finished, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypewriterReducedMotionAndInvalid(t *testing.T) {
|
||||
finished := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
finished = Typewriter(c, "All at once", TypewriterOptions{}).Changed() || finished
|
||||
}, 300, 80)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
_ = tt
|
||||
tt2 := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
finished = Typewriter(c, "Shown now", TypewriterOptions{}).Changed() || finished
|
||||
}, 300, 80)
|
||||
tt2.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionCount(tt2, "Shown now") != 2 || !finished {
|
||||
t.Fatalf("reduced motion: %q", tt2.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("negative speed did not panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); Typewriter(c, "x", TypewriterOptions{Speed: -1}) }, 100, 50, 1)
|
||||
}
|
||||
|
||||
func TestTypewriterSnapshot(t *testing.T) {
|
||||
snapshot(t, "typewriter", 420, 100, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Typewriter(c, "The court assembles at dusk; the music begins.", TypewriterOptions{Skippable: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTypewriterReplacementRestartsClock(t *testing.T) {
|
||||
text := "A long opening message"
|
||||
tt, _ := motionFrames(func(c *ui.Context) {
|
||||
Typewriter(c, text, TypewriterOptions{Speed: 10})
|
||||
}, 300, 100)
|
||||
time.Sleep(220 * time.Millisecond)
|
||||
text = "New letter"
|
||||
tt.Frame()
|
||||
if motionCount(tt, "Ne") > 0 {
|
||||
t.Fatalf("replacement consumed old elapsed time: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypewriterInvalidSpeedBoundary(t *testing.T) {
|
||||
for _, speed := range []float64{nanValue(), math.Inf(1), 2e9} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("invalid speed %v accepted", speed)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Typewriter(c, "text", TypewriterOptions{Speed: speed})
|
||||
}, 200, 100, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypewriterMinimumIntervalBoundary(t *testing.T) {
|
||||
minimum := float64(time.Second) / float64(math.MaxInt64)
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
Typewriter(c, "letter", TypewriterOptions{Speed: minimum})
|
||||
}, 200, 100)
|
||||
if motionCount(tt, "letter") != 1 || motionBusy(tt, n) {
|
||||
t.Fatal("slow typing should wait with partial text")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unrepresentable interval accepted")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Typewriter(c, "letter", TypewriterOptions{Speed: math.Nextafter(minimum, 0)})
|
||||
}, 200, 100, 1)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// UndoToastOptions configure an UndoToast.
|
||||
type UndoToastOptions struct {
|
||||
// OnUndo is required; it runs when the user undoes.
|
||||
OnUndo func()
|
||||
// Duration is the countdown; zero is ToastDuration.
|
||||
Duration time.Duration
|
||||
// Action labels the button; empty uses "Undo".
|
||||
Action string
|
||||
}
|
||||
|
||||
// UndoToastResult reports how an UndoToast ended this frame, if it did.
|
||||
type UndoToastResult struct {
|
||||
Element *ui.Element
|
||||
Undone bool
|
||||
// Expired: the countdown ran out, or the user closed it; the action stands.
|
||||
Expired bool
|
||||
}
|
||||
|
||||
// undoToastClock counts the time shown, stopped while hovered or focused.
|
||||
type undoToastClock struct {
|
||||
message string
|
||||
spent time.Duration
|
||||
runFrom time.Time
|
||||
}
|
||||
|
||||
// UndoToast shows message at the bottom of the window with a countdown while *open; it never takes the focus.
|
||||
func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult {
|
||||
if open == nil || message == "" || opts.OnUndo == nil {
|
||||
panic("mujica: UndoToast needs open, a message and OnUndo")
|
||||
}
|
||||
if opts.Duration < 0 {
|
||||
panic("mujica: negative UndoToast Duration")
|
||||
}
|
||||
clock := ui.Local(c.Root(), "undo_toast", func() *undoToastClock { return &undoToastClock{} })
|
||||
s := *clock
|
||||
var res UndoToastResult
|
||||
if !*open {
|
||||
*s = undoToastClock{}
|
||||
return res
|
||||
}
|
||||
now := c.Now()
|
||||
life := cmp.Or(opts.Duration, ToastDuration)
|
||||
if s.message != message {
|
||||
*s = undoToastClock{message: message, runFrom: now}
|
||||
c.Announce(message)
|
||||
}
|
||||
spent := s.spent
|
||||
if !s.runFrom.IsZero() {
|
||||
spent += now.Sub(s.runFrom)
|
||||
}
|
||||
if spent >= life {
|
||||
*open, res.Expired = false, true
|
||||
*s = undoToastClock{}
|
||||
slog.Debug("mujica: undo_toast expired")
|
||||
return res
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ww, _ := c.Size()
|
||||
left := life - spent
|
||||
ui.Overlay(c, func() {
|
||||
dock := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 4).Justify(ui.Center).PassThrough()
|
||||
dock.Children(func() {
|
||||
card := ui.Column(c).Width(min(420, ww-8*u)).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.ControlBorder).Role(ui.RoleStatus).Label(message).Clip()
|
||||
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{}})
|
||||
res.Element = card
|
||||
card.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).Children(func() {
|
||||
ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1)
|
||||
secs := int(math.Ceil(left.Seconds()))
|
||||
label := fmt.Sprintf("%s (%d)", cmp.Or(opts.Action, core.Msg(c, "undo_toast.undo")), secs)
|
||||
if input.Button(c, label, input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("refresh-cw")}).Clicked() {
|
||||
res.Undone = true
|
||||
}
|
||||
if core.IconAction(c, icons.Must("x"), core.Msg(c, "toast.close")).Clicked() {
|
||||
res.Expired = true
|
||||
}
|
||||
})
|
||||
still := c.Preferences().ReduceMotion
|
||||
ui.Box(c).FillWidth().Height(2).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if still {
|
||||
return
|
||||
}
|
||||
rest := left
|
||||
if !s.runFrom.IsZero() {
|
||||
rest = life - s.spent - p.Now().Sub(s.runFrom)
|
||||
p.AnimationFrame()
|
||||
}
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * max(0, float32(rest)/float32(life)), H: r.H}, k.AccentText, 0)
|
||||
})
|
||||
})
|
||||
if card.Shortcut(0, ui.KeyEscape) {
|
||||
res.Expired = true
|
||||
}
|
||||
// Hover or focus holds the countdown.
|
||||
switch held := card.Hovered() || card.FocusWithin(); {
|
||||
case held && !s.runFrom.IsZero():
|
||||
s.spent, s.runFrom = spent, time.Time{}
|
||||
case !held && s.runFrom.IsZero():
|
||||
s.runFrom = now
|
||||
}
|
||||
})
|
||||
})
|
||||
switch {
|
||||
case res.Undone:
|
||||
*open, res.Expired = false, false
|
||||
*s = undoToastClock{}
|
||||
slog.Debug("mujica: undo_toast undo")
|
||||
opts.OnUndo()
|
||||
case res.Expired:
|
||||
*open = false
|
||||
*s = undoToastClock{}
|
||||
slog.Debug("mujica: undo_toast closed")
|
||||
case !s.runFrom.IsZero():
|
||||
// Wake for the next whole second of the count, and for the end.
|
||||
c.After(min(left, left-left.Truncate(time.Second)+time.Millisecond))
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func undoTester(open *bool, life time.Duration, undos *int, last *UndoToastResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if input.Button(c, "Delete", input.ButtonOptions{}).Clicked() {
|
||||
*open = true
|
||||
}
|
||||
if r := UndoToast(c, open, "File deleted", UndoToastOptions{Duration: life, OnUndo: func() { *undos++ }}); r.Undone || r.Expired {
|
||||
*last = r
|
||||
}
|
||||
}, 600, 400)
|
||||
}
|
||||
|
||||
func TestUndoToastUndoAndFocus(t *testing.T) {
|
||||
open, undos := false, 0
|
||||
var last UndoToastResult
|
||||
tt := undoTester(&open, 0, &undos, &last)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("File deleted") || !tt.HasText("Undo (5)") || !tt.Focused("Delete") {
|
||||
t.Fatalf("shown %q, focus kept %v", tt.Texts(), tt.Focused("Delete"))
|
||||
}
|
||||
if !slices.Contains(tt.Announcements(), "File deleted") {
|
||||
t.Fatal("not announced")
|
||||
}
|
||||
if err := tt.Click("Undo (5)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if undos != 1 || open || !last.Undone || tt.HasText("File deleted") {
|
||||
t.Fatalf("undo: %d %v %+v", undos, open, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoToastCountdownPauseAndClose(t *testing.T) {
|
||||
open, undos := false, 0
|
||||
var last UndoToastResult
|
||||
tt := undoTester(&open, 60*time.Millisecond, &undos, &last)
|
||||
_ = tt.Click("Delete")
|
||||
r, _ := tt.Find("File deleted")
|
||||
tt.Move(r.X+r.W/2, r.Y+r.H/2)
|
||||
time.Sleep(90 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !open {
|
||||
t.Fatal("hovered countdown ran out")
|
||||
}
|
||||
tt.Move(5, 5)
|
||||
time.Sleep(90 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if open || !last.Expired || undos != 0 {
|
||||
t.Fatalf("expiry: %v %+v %d", open, last, undos)
|
||||
}
|
||||
last = UndoToastResult{}
|
||||
_ = tt.Click("Delete")
|
||||
_ = tt.Click("Dismiss notification")
|
||||
if open || !last.Expired || undos != 0 {
|
||||
t.Fatalf("close: %v %+v", open, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoToastRejects(t *testing.T) {
|
||||
open := true
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"no undo": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{}) },
|
||||
"negative": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{OnUndo: func() {}, Duration: -1}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoToastSnapshot(t *testing.T) {
|
||||
open := true
|
||||
snapshot(t, "undo_toast", 520, 160, func(c *ui.Context) {
|
||||
UndoToast(c, &open, "3 messages archived", UndoToastOptions{OnUndo: func() {}, Duration: time.Hour})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// WaveLoader draws a sine line flowing to the right.
|
||||
func WaveLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, "wave_loader", 2.5, 1400, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
var path ui.Path
|
||||
amp := r.H * 0.3
|
||||
const steps = 24
|
||||
for i := 0; i <= steps; i++ {
|
||||
f := float64(i) / steps
|
||||
x := r.X + r.W*float32(f)
|
||||
y := r.Y + r.H/2 - amp*float32(math.Sin(2*math.Pi*(1.5*f-t)))
|
||||
x, y = internal.Snap(p, x), internal.Snap(p, y)
|
||||
if i == 0 {
|
||||
path.MoveTo(x, y)
|
||||
} else {
|
||||
path.LineTo(x, y)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&path, max(1.5, r.H/10), col)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWaveLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "wave_loader", WaveLoader) }
|
||||
|
||||
func TestWaveLoaderBehavior(t *testing.T) { testLoaderBehavior(t, WaveLoader) }
|
||||
Reference in new issue
Block a user