Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+139
View File
@@ -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()
})
}
+94
View File
@@ -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)
})
})
}
+76
View File
@@ -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
}
+115
View File
@@ -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"})
})
})
}
+23
View File
@@ -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)
}
})
}
+9
View File
@@ -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) }
+84
View File
@@ -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
}
+88
View File
@@ -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")
}
}
+163
View File
@@ -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
}
+154
View File
@@ -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")
}()
}
}
+71
View File
@@ -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() })
}
+132
View File
@@ -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)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package feedback holds MujicaUI alerts, toasts, progress, empty states and agent ops loaders and motion.
package feedback
+81
View File
@@ -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))
}
})
}
+9
View File
@@ -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) }
+90
View File
@@ -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
}
+82
View File
@@ -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"})
})
})
})
}
+137
View File
@@ -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
}
+144
View File
@@ -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")
}
}
+34
View File
@@ -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)
}
+139
View File
@@ -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)
}
+223
View File
@@ -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)
}
+160
View File
@@ -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")
}
}
+72
View File
@@ -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++
}
})
}
+64
View File
@@ -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
}
+102
View File
@@ -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")
}
}
+43
View File
@@ -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
}
+57
View File
@@ -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)
})
})
})
}
+107
View File
@@ -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})
})
})
}
+94
View File
@@ -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
}
+122
View File
@@ -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{})
})
})
})
})
}
+193
View File
@@ -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
}
+132
View File
@@ -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{})
})
})
}
+26
View File
@@ -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)
})
}
+9
View File
@@ -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) }
+76
View File
@@ -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
}
+87
View File
@@ -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")
}
}
+273
View File
@@ -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) }
+176
View File
@@ -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}})
})
})
}
+21
View File
@@ -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))
})
}
+9
View File
@@ -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) }
+67
View File
@@ -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
}
+82
View File
@@ -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")
}
}
+94
View File
@@ -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)
})
}
+68
View File
@@ -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"},
})
})
}
+72
View File
@@ -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
}
+59
View File
@@ -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") })
})
})
}
+45
View File
@@ -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
}
+78
View File
@@ -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)
}
}
}
}
}
+92
View File
@@ -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
}
+84
View File
@@ -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})
})
})
}
+73
View File
@@ -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
}
+73
View File
@@ -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")
}
}
+79
View File
@@ -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
}
+71
View File
@@ -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{})
}
})
})
}
+274
View File
@@ -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()
}
+211
View File
@@ -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)
})
}
+83
View File
@@ -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
}
+111
View File
@@ -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")
}
}
+144
View File
@@ -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)
})
}
+150
View File
@@ -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()))
}
}
+217
View File
@@ -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())
}
+163
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+131
View File
@@ -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)
}
+138
View File
@@ -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
}
+94
View File
@@ -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})
})
}
+30
View File
@@ -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)
})
}
+9
View File
@@ -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) }