diff --git a/blink_highlight_test.go b/blink_highlight_test.go new file mode 100644 index 0000000..ebc9e59 --- /dev/null +++ b/blink_highlight_test.go @@ -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() }) +} diff --git a/cost_breakdown.go b/cost_breakdown.go index b86296c..7b88a78 100644 --- a/cost_breakdown.go +++ b/cost_breakdown.go @@ -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) { diff --git a/cost_breakdown_test.go b/cost_breakdown_test.go new file mode 100644 index 0000000..0445900 --- /dev/null +++ b/cost_breakdown_test.go @@ -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) + }) + }) +} diff --git a/count_up_test.go b/count_up_test.go new file mode 100644 index 0000000..a5e2bc9 --- /dev/null +++ b/count_up_test.go @@ -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) }}) + }) + }) +} diff --git a/eval_result_table_test.go b/eval_result_table_test.go new file mode 100644 index 0000000..80051fd --- /dev/null +++ b/eval_result_table_test.go @@ -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) + }) + }) +} diff --git a/interrupt_button_test.go b/interrupt_button_test.go new file mode 100644 index 0000000..d7cd259 --- /dev/null +++ b/interrupt_button_test.go @@ -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"}) + }) + }) +} diff --git a/messages_motion.go b/messages_motion.go index cc4cc5a..8597473 100644 --- a/messages_motion.go +++ b/messages_motion.go @@ -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", "跳过"}, diff --git a/shimmer.go b/shimmer.go index b4df863..174a960 100644 --- a/shimmer.go +++ b/shimmer.go @@ -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 diff --git a/shimmer_test.go b/shimmer_test.go new file mode 100644 index 0000000..7479e93 --- /dev/null +++ b/shimmer_test.go @@ -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{}) + }) + }) +} diff --git a/testdata/snapshots/darwin/blink_highlight-dark.png b/testdata/snapshots/darwin/blink_highlight-dark.png new file mode 100644 index 0000000..c2c3624 Binary files /dev/null and b/testdata/snapshots/darwin/blink_highlight-dark.png differ diff --git a/testdata/snapshots/darwin/blink_highlight-light.png b/testdata/snapshots/darwin/blink_highlight-light.png new file mode 100644 index 0000000..8b68578 Binary files /dev/null and b/testdata/snapshots/darwin/blink_highlight-light.png differ diff --git a/testdata/snapshots/darwin/cost_breakdown-dark.png b/testdata/snapshots/darwin/cost_breakdown-dark.png new file mode 100644 index 0000000..7cbd2e4 Binary files /dev/null and b/testdata/snapshots/darwin/cost_breakdown-dark.png differ diff --git a/testdata/snapshots/darwin/cost_breakdown-light.png b/testdata/snapshots/darwin/cost_breakdown-light.png new file mode 100644 index 0000000..2313ff7 Binary files /dev/null and b/testdata/snapshots/darwin/cost_breakdown-light.png differ diff --git a/testdata/snapshots/darwin/count_up-dark.png b/testdata/snapshots/darwin/count_up-dark.png new file mode 100644 index 0000000..aecbad5 Binary files /dev/null and b/testdata/snapshots/darwin/count_up-dark.png differ diff --git a/testdata/snapshots/darwin/count_up-light.png b/testdata/snapshots/darwin/count_up-light.png new file mode 100644 index 0000000..f04253c Binary files /dev/null and b/testdata/snapshots/darwin/count_up-light.png differ diff --git a/testdata/snapshots/darwin/eval_result_table-dark.png b/testdata/snapshots/darwin/eval_result_table-dark.png new file mode 100644 index 0000000..6a3fdf0 Binary files /dev/null and b/testdata/snapshots/darwin/eval_result_table-dark.png differ diff --git a/testdata/snapshots/darwin/eval_result_table-light.png b/testdata/snapshots/darwin/eval_result_table-light.png new file mode 100644 index 0000000..96502fd Binary files /dev/null and b/testdata/snapshots/darwin/eval_result_table-light.png differ diff --git a/testdata/snapshots/darwin/interrupt_button-dark.png b/testdata/snapshots/darwin/interrupt_button-dark.png new file mode 100644 index 0000000..6a1ddf4 Binary files /dev/null and b/testdata/snapshots/darwin/interrupt_button-dark.png differ diff --git a/testdata/snapshots/darwin/interrupt_button-light.png b/testdata/snapshots/darwin/interrupt_button-light.png new file mode 100644 index 0000000..a4f5601 Binary files /dev/null and b/testdata/snapshots/darwin/interrupt_button-light.png differ diff --git a/testdata/snapshots/darwin/layout_transition-dark.png b/testdata/snapshots/darwin/layout_transition-dark.png new file mode 100644 index 0000000..c38afe9 Binary files /dev/null and b/testdata/snapshots/darwin/layout_transition-dark.png differ diff --git a/testdata/snapshots/darwin/layout_transition-light.png b/testdata/snapshots/darwin/layout_transition-light.png new file mode 100644 index 0000000..b0f6b3e Binary files /dev/null and b/testdata/snapshots/darwin/layout_transition-light.png differ diff --git a/testdata/snapshots/darwin/presence-dark.png b/testdata/snapshots/darwin/presence-dark.png new file mode 100644 index 0000000..dd58ee3 Binary files /dev/null and b/testdata/snapshots/darwin/presence-dark.png differ diff --git a/testdata/snapshots/darwin/presence-light.png b/testdata/snapshots/darwin/presence-light.png new file mode 100644 index 0000000..0f1422d Binary files /dev/null and b/testdata/snapshots/darwin/presence-light.png differ diff --git a/testdata/snapshots/darwin/reorder_transition-dark.png b/testdata/snapshots/darwin/reorder_transition-dark.png new file mode 100644 index 0000000..888af78 Binary files /dev/null and b/testdata/snapshots/darwin/reorder_transition-dark.png differ diff --git a/testdata/snapshots/darwin/reorder_transition-light.png b/testdata/snapshots/darwin/reorder_transition-light.png new file mode 100644 index 0000000..466fc25 Binary files /dev/null and b/testdata/snapshots/darwin/reorder_transition-light.png differ diff --git a/testdata/snapshots/darwin/shimmer-dark.png b/testdata/snapshots/darwin/shimmer-dark.png new file mode 100644 index 0000000..524c841 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer-dark.png differ diff --git a/testdata/snapshots/darwin/shimmer-light.png b/testdata/snapshots/darwin/shimmer-light.png new file mode 100644 index 0000000..ce924ab Binary files /dev/null and b/testdata/snapshots/darwin/shimmer-light.png differ diff --git a/testdata/snapshots/darwin/shimmer_text-dark.png b/testdata/snapshots/darwin/shimmer_text-dark.png new file mode 100644 index 0000000..82d6c55 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer_text-dark.png differ diff --git a/testdata/snapshots/darwin/shimmer_text-light.png b/testdata/snapshots/darwin/shimmer_text-light.png new file mode 100644 index 0000000..06cd472 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer_text-light.png differ diff --git a/testdata/snapshots/darwin/stagger-dark.png b/testdata/snapshots/darwin/stagger-dark.png new file mode 100644 index 0000000..3867f81 Binary files /dev/null and b/testdata/snapshots/darwin/stagger-dark.png differ diff --git a/testdata/snapshots/darwin/stagger-light.png b/testdata/snapshots/darwin/stagger-light.png new file mode 100644 index 0000000..ccb86b9 Binary files /dev/null and b/testdata/snapshots/darwin/stagger-light.png differ diff --git a/testdata/snapshots/darwin/token_usage_chart-dark.png b/testdata/snapshots/darwin/token_usage_chart-dark.png new file mode 100644 index 0000000..71af2ee Binary files /dev/null and b/testdata/snapshots/darwin/token_usage_chart-dark.png differ diff --git a/testdata/snapshots/darwin/token_usage_chart-light.png b/testdata/snapshots/darwin/token_usage_chart-light.png new file mode 100644 index 0000000..812efcb Binary files /dev/null and b/testdata/snapshots/darwin/token_usage_chart-light.png differ diff --git a/testdata/snapshots/darwin/tool_registry_panel-dark.png b/testdata/snapshots/darwin/tool_registry_panel-dark.png new file mode 100644 index 0000000..0dc412f Binary files /dev/null and b/testdata/snapshots/darwin/tool_registry_panel-dark.png differ diff --git a/testdata/snapshots/darwin/tool_registry_panel-light.png b/testdata/snapshots/darwin/tool_registry_panel-light.png new file mode 100644 index 0000000..8ed4916 Binary files /dev/null and b/testdata/snapshots/darwin/tool_registry_panel-light.png differ diff --git a/testdata/snapshots/darwin/trace_viewer-dark.png b/testdata/snapshots/darwin/trace_viewer-dark.png new file mode 100644 index 0000000..2846673 Binary files /dev/null and b/testdata/snapshots/darwin/trace_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/trace_viewer-light.png b/testdata/snapshots/darwin/trace_viewer-light.png new file mode 100644 index 0000000..a1f5863 Binary files /dev/null and b/testdata/snapshots/darwin/trace_viewer-light.png differ diff --git a/testdata/snapshots/darwin/typewriter-dark.png b/testdata/snapshots/darwin/typewriter-dark.png new file mode 100644 index 0000000..4573c7f Binary files /dev/null and b/testdata/snapshots/darwin/typewriter-dark.png differ diff --git a/testdata/snapshots/darwin/typewriter-light.png b/testdata/snapshots/darwin/typewriter-light.png new file mode 100644 index 0000000..ee60d53 Binary files /dev/null and b/testdata/snapshots/darwin/typewriter-light.png differ diff --git a/token_usage_chart_test.go b/token_usage_chart_test.go new file mode 100644 index 0000000..4e6d21a --- /dev/null +++ b/token_usage_chart_test.go @@ -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{}) + }) + }) +} diff --git a/tool_registry_panel.go b/tool_registry_panel.go index 93867ac..cafa661 100644 --- a/tool_registry_panel.go +++ b/tool_registry_panel.go @@ -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) diff --git a/tool_registry_panel_test.go b/tool_registry_panel_test.go new file mode 100644 index 0000000..8e93561 --- /dev/null +++ b/tool_registry_panel_test.go @@ -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") }) +} diff --git a/trace_viewer.go b/trace_viewer.go index b562cd1..7304c72 100644 --- a/trace_viewer.go +++ b/trace_viewer.go @@ -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) }) }}, } diff --git a/trace_viewer_test.go b/trace_viewer_test.go new file mode 100644 index 0000000..f6b3de1 --- /dev/null +++ b/trace_viewer_test.go @@ -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) + }) + }) +} diff --git a/transitions_test.go b/transitions_test.go new file mode 100644 index 0000000..e42cfa3 --- /dev/null +++ b/transitions_test.go @@ -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) + }) + }) + }) +} diff --git a/typewriter.go b/typewriter.go index 4d23dcb..b1bc810 100644 --- a/typewriter.go +++ b/typewriter.go @@ -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) diff --git a/typewriter_test.go b/typewriter_test.go new file mode 100644 index 0000000..23f2103 --- /dev/null +++ b/typewriter_test.go @@ -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}) + }) + }) +}