WIP motion 2

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:56:58 -04:00
1 parent ec26a33995
commit eb6e0752d6
47 files changed
+1067 -20

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
package mujica
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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() })
}
+2 -2
View File
@@ -91,8 +91,8 @@ func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts
cols := []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: 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: msg(c, "cost_breakdown.input"), Width: 110, 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: msg(c, "cost_breakdown.output"), Width: 110, 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: "input", Title: 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: 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: 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) {
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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)
})
})
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) { Use(c, 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) { Use(c, 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(FontSize(c, 28)).Font("Georgia, serif")
CountUp(c, 0.875, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }})
})
})
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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 DataTableState[string]
diff := ""
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *DataTableState[string] { return &DataTableState[string]{} })
ui.Column(c).Padding(16).Height(300).Children(func() {
EvalResultTable(c, *s, cases, EvalResultTableOptions{}).Table.Element.Grow(1)
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestInterruptButtonDirectAndConfirmed(t *testing.T) {
running, interrupting, confirm := true, false, false
stops := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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"})
})
})
}
+1
View File
@@ -45,6 +45,7 @@ func init() {
"tool_registry_panel.empty": {"No tools", "没有工具"},
"tool_registry_panel.choose": {"Choose a tool to see its parameters", "选择工具查看参数"},
"tool_registry_panel.no_params": {"No parameters", "无参数"},
"tool_registry_panel.enable": {"Enable %s", "启用 %s"},
"tool_registry_panel.required": {"required", "必填"},
"typewriter.skip": {"Skip", "跳过"},
+15 -12
View File
@@ -45,23 +45,26 @@ func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *
fill = k.Border
}
still := c.Preferences().ReduceMotion
e := ui.Box(c).Key(still).Height(h).Role(ui.RoleProgress).Label(label)
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()
}
e.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)
// 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
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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") })
})
})
}
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) {
Use(c, 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{})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) 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) ChartResult {
return TokenUsageChart(c, usage, TokenUsageChartOptions{Line: true, Frame: 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) {
Use(c, 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{})
})
})
}
+2 -1
View File
@@ -1,6 +1,7 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
@@ -66,7 +67,7 @@ func ToolRegistryPanel(c *ui.Context, s *ListState[string], tools []AgentTool, o
ui.Text(c, t.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
on := t.Enabled
Switch(c, &on, "", SwitchOptions{Description: t.Name}).Label(t.Name)
Switch(c, &on, "", SwitchOptions{}).Label(fmt.Sprintf(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)
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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 ListState[string]
tools := toolSample()
disabled := false
var toggled []string
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, Settings{})
var s 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() *ListState[string] { return &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") })
}
+12 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
@@ -70,6 +71,9 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra
{ID: "name", Title: 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 + ", " + msg(c, "trace_viewer.failed"))
@@ -95,7 +99,14 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra
}
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)))
p.Fill(ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: internal.Snap(p, w), H: r.H}, col, 2)
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)
})
}},
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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"}},
} {
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) {
Use(c, 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)
})
})
}
+210
View File
@@ -0,0 +1,210 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) { Use(c, Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1)
}
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) {
Use(c, 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) { Use(c, Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1)
}
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) {
Use(c, Settings{})
ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {})
}, 100, 50, 1)
}
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) { Use(c, Settings{}); LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, 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 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 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(Tokens(c).Surface).Radius(4)
})
})
})
}
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(Tokens(c).Surface).Radius(4)
})
})
})
}
+5 -4
View File
@@ -42,7 +42,7 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
if speed < 0 {
panic("mujica: Typewriter Speed is negative")
}
col := ui.Column(c).AlignItems(ui.Start).Gap(Density(c).Unit() * 2)
col := ui.Column(c).AlignItems(ui.Start).Gap(Density(c).Unit() * 2).Role(ui.RoleGroup).Label(text)
res := TypewriterResult{Element: col}
st := ui.Local(col, "typing", func() *typewriterState { return &typewriterState{last: c.Now()} })
s := *st
@@ -51,8 +51,9 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
s.shown, s.skipped, s.done = 0, false, false
slog.Debug("mujica: typewriter restart")
}
if len(runes) > len(s.text) {
s.done = false
if len(runes) > len(s.text) && s.done {
// New text continues from where it stood, at the typing speed.
s.done, s.skipped, s.last = false, false, c.Now()
}
s.text = runes
now := c.Now()
@@ -71,7 +72,7 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe
s.last = now
}
col.Children(func() {
ui.Text(c, string(runes[:s.shown])).Label(text)
ui.Text(c, string(runes[:s.shown]))
if opts.Skippable && s.shown < len(runes) {
if Button(c, msg(c, "typewriter.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
s.skipped, s.shown = true, len(runes)
+80
View File
@@ -0,0 +1,80 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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}).Finished() {
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) {
Use(c, Settings{})
finished = Typewriter(c, "All at once", TypewriterOptions{}).Finished() || finished
}, 300, 80)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
_ = tt
tt2 := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
finished = Typewriter(c, "Shown now", TypewriterOptions{}).Finished() || 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) { Use(c, 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})
})
})
}