Merge branch 'worktree-agent-acb1218b465c60e42'
No files matched your search
@@ -0,0 +1,23 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// BarsLoader shows four bars rising and falling in turn.
|
||||
func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1.25, 1000, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
w := r.W / 7
|
||||
for i := range 4 {
|
||||
h := float32(0.6)
|
||||
if !still {
|
||||
h = 0.3 + 0.7*loaderWave(t-float64(i)/8)
|
||||
}
|
||||
bh := internal.Snap(p, r.H*h)
|
||||
x := r.X + float32(i)*w*2
|
||||
p.Fill(ui.Rect{X: x, Y: internal.Snap(p, r.Y+(r.H-bh)/2), W: w, H: bh}, col, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"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 800 ms.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type blinkState struct {
|
||||
value float64
|
||||
dir int
|
||||
at time.Time
|
||||
set bool
|
||||
}
|
||||
|
||||
// BlinkHighlight shows value and tints it once when it changes, up in Success, down in Danger.
|
||||
// An arrow marks the last direction, and stays as the only mark under reduced motion.
|
||||
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 = 800 * time.Millisecond
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := 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
|
||||
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 += ", " + msg(c, word)
|
||||
}
|
||||
row.Label(name)
|
||||
if dur := Motion(c, d); st.dir != 0 && dur > 0 {
|
||||
if p := float32(now.Sub(st.at)) / float32(dur); p < 1 {
|
||||
row.Background(col.Alpha(0.22 * (1 - p)))
|
||||
c.AnimationFrame()
|
||||
}
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Text(c, format(value)).FontFeatures("tnum").SingleLine()
|
||||
if st.dir != 0 {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(col)
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,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() })
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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
|
||||
// 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 DataTableState[string]
|
||||
rows []costRow
|
||||
total costRow
|
||||
src *CostItem
|
||||
n int
|
||||
group CostGroup
|
||||
built bool
|
||||
}
|
||||
|
||||
type costRow struct {
|
||||
name string
|
||||
calls int
|
||||
input, output int
|
||||
cost float64
|
||||
}
|
||||
|
||||
// CostBreakdown sums costs by model, tool or step in a sortable table with a total; give it a height.
|
||||
func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts CostBreakdownOptions) 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]
|
||||
}
|
||||
if !s.built || s.src != first || s.n != len(items) || s.group != opts.GroupBy {
|
||||
s.rows, s.total = costSum(items, opts.GroupBy, msg(c, "cost_breakdown.unnamed"))
|
||||
s.src, s.n, s.group, s.built = first, len(items), opts.GroupBy, true
|
||||
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 := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
groupTitle := msg(c, []string{"cost_breakdown.model", "cost_breakdown.tool", "cost_breakdown.step"}[opts.GroupBy])
|
||||
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: 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) {
|
||||
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(FontSize(c, theme.CaptionSize)).Width(36).TextAlign(ui.End).Shrink(0)
|
||||
})
|
||||
}},
|
||||
}
|
||||
var v DataTableView
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
col.Children(func() {
|
||||
v = DataTable(c, &s.Table, s.rows, DataTableOptions[costRow, string]{Columns: cols, Key: func(r costRow) string { return r.name }, Empty: 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(msg(c, "cost_breakdown.total_name"), money(s.total.cost), s.total.calls)).Children(func() {
|
||||
ui.Text(c, msg(c, "cost_breakdown.total")).Font(theme.SerifFont).FontWeight(600).Grow(1)
|
||||
ui.Text(c, fmt.Sprintf(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} {
|
||||
r.calls++
|
||||
r.input += it.InputTokens
|
||||
r.output += it.OutputTokens
|
||||
r.cost += it.Cost
|
||||
}
|
||||
}
|
||||
return rows, total
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// CountUpOptions configure a CountUp.
|
||||
type CountUpOptions struct {
|
||||
// Duration of a change; zero means 600 ms.
|
||||
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
|
||||
set bool
|
||||
}
|
||||
|
||||
// CountUp counts from the last value to value; reduced motion shows it at once.
|
||||
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 = 600 * time.Millisecond
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', opts.Decimals, 64) }
|
||||
}
|
||||
dur := 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))
|
||||
return st.from + (st.to-st.from)*float64(ui.EaseOut(float32(p)))
|
||||
}
|
||||
if value != st.to {
|
||||
from := at()
|
||||
slog.Debug("mujica: count up", "from", from, "to", value)
|
||||
st.from, st.to, st.start = from, value, now
|
||||
}
|
||||
shown := at()
|
||||
if shown != st.to {
|
||||
c.AnimationFrame()
|
||||
}
|
||||
return row.Children(func() { ui.Text(c, format(shown)).FontFeatures("tnum").SingleLine() })
|
||||
}
|
||||
@@ -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) }})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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, and shown with ShowLabel; empty means "Loading".
|
||||
Label string
|
||||
ShowLabel bool
|
||||
// Color paints the mark; zero uses AccentText.
|
||||
Color ui.Color
|
||||
}
|
||||
|
||||
// loaderDraw paints a loader at phase t in [0, 1); t is 0 and still is set under reduced motion.
|
||||
type loaderDraw func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool)
|
||||
|
||||
// loaderMark builds a loader of aspect w:1 that repaints only while painted and moving.
|
||||
func loaderMark(c *ui.Context, opts LoaderOptions, 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 := Tokens(c)
|
||||
size := opts.Size
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "loading")
|
||||
}
|
||||
col := opts.Color
|
||||
if col.A == 0 {
|
||||
col = k.AccentText
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
row := ui.Row(c).Gap(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(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// loaderWave is a smooth 0..1 rise and fall over phase t.
|
||||
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, 2.5, 1200, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
d := r.H * 0.36
|
||||
for i := range 3 {
|
||||
a := float32(1)
|
||||
if !still {
|
||||
a = 0.3 + 0.7*loaderWave(t-float64(i)/6)
|
||||
}
|
||||
var dot ui.Path
|
||||
cx := r.X + r.W*(float32(i)+0.5)/3
|
||||
dot.Circle(cx, r.Y+r.H/2, d/2)
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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 DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// EvalResultTableResult is a built EvalResultTable and its events.
|
||||
type EvalResultTableResult struct {
|
||||
Table DataTableView
|
||||
diff string
|
||||
}
|
||||
|
||||
// Diff returns the case whose differences the user asked to see this frame.
|
||||
func (r EvalResultTableResult) Diff() (string, bool) { return r.diff, r.diff != "" }
|
||||
|
||||
// EvalResultTable lists eval cases with score and pass or fail, sortable, with a pass summary; give it a height.
|
||||
func EvalResultTable(c *ui.Context, s *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
|
||||
}
|
||||
passed := 0
|
||||
for _, ec := range cases {
|
||||
if ec.ID == "" || math.IsNaN(ec.Score) || math.IsInf(ec.Score, 0) {
|
||||
panic("mujica: EvalCase needs an ID and a finite Score")
|
||||
}
|
||||
if ec.Passed {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var res EvalResultTableResult
|
||||
verdict := func(ec EvalCase) string {
|
||||
if ec.Passed {
|
||||
return msg(c, "eval_result_table.pass")
|
||||
}
|
||||
return msg(c, "eval_result_table.fail")
|
||||
}
|
||||
cols := []DataColumn[EvalCase]{
|
||||
{ID: "name", Title: 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: 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: 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 := iconAction(c, icons.Must("arrow-left-right"), fmt.Sprintf(msg(c, "eval_result_table.diff"), e.Name)).Tooltip(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 := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
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(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 = DataTable(c, s, cases, DataTableOptions[EvalCase, string]{Columns: cols, Key: func(e EvalCase) string { return e.ID },
|
||||
Empty: msg(c, "eval_result_table.empty"), Status: opts.Status, Error: opts.Error})
|
||||
res.Table.Element.Grow(1)
|
||||
})
|
||||
res.Table.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
func evalRank(e EvalCase) int {
|
||||
if e.Passed {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// 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, confirmed when asked.
|
||||
func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) ChoiceResult {
|
||||
if opts.Interrupting && !running {
|
||||
panic("mujica: InterruptButton cannot be Interrupting while not running")
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "interrupt_button.stop")
|
||||
}
|
||||
shown := label
|
||||
if opts.Interrupting {
|
||||
shown = msg(c, "interrupt_button.stopping")
|
||||
}
|
||||
live := running && !opts.Interrupting
|
||||
var res ChoiceResult
|
||||
wrap := ui.Row(c).Shrink(0)
|
||||
res.Element = wrap
|
||||
wrap.Children(func() {
|
||||
b := Button(c, shown, ButtonOptions{Variant: 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
|
||||
switch {
|
||||
case clicked && opts.Confirm:
|
||||
*open = true
|
||||
case clicked:
|
||||
res.changed = true
|
||||
}
|
||||
r := Popconfirm(c, b, open, PopconfirmOptions{Title: msg(c, "interrupt_button.confirm"),
|
||||
Description: msg(c, "interrupt_button.detail"), ConfirmLabel: label, CancelLabel: msg(c, "interrupt_button.keep"), Danger: true})
|
||||
if r.Confirmed && live {
|
||||
res.changed = true
|
||||
}
|
||||
})
|
||||
if res.changed {
|
||||
slog.Debug("mujica: interrupt button interrupt")
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// LayoutTransitionOptions configure a LayoutTransition.
|
||||
type LayoutTransitionOptions struct {
|
||||
// Duration of a change; zero means 200 ms.
|
||||
Duration time.Duration
|
||||
// Row lays the content in a row instead of a column.
|
||||
Row bool
|
||||
// Clip hides content outside the box while it resizes.
|
||||
Clip bool
|
||||
}
|
||||
|
||||
// LayoutTransition holds content whose size and place animate from where they were (FLIP).
|
||||
// Siblings that the change moves need a LayoutTransition of their own to move in step.
|
||||
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 = 200 * time.Millisecond
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if opts.Row {
|
||||
box.Row()
|
||||
}
|
||||
if opts.Clip {
|
||||
box.Clip()
|
||||
}
|
||||
box.Transition(ui.ElementTransition{Duration: d, Position: true, Size: true})
|
||||
if content != nil {
|
||||
box.Children(content)
|
||||
}
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// 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++
|
||||
Use(c, 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 TestLoadersMoveStopAndLabel(t *testing.T) {
|
||||
for name, loader := range loaderCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
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) {
|
||||
Use(c, 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: Tokens(c).Info})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDotsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "dots_loader", DotsLoader) }
|
||||
func TestBarsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "bars_loader", BarsLoader) }
|
||||
func TestPulseLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "pulse_loader", PulseLoader) }
|
||||
func TestOrbitLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "orbit_loader", OrbitLoader) }
|
||||
func TestWaveLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "wave_loader", WaveLoader) }
|
||||
@@ -0,0 +1,56 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"interrupt_button.stop": {"Stop", "停止"},
|
||||
"interrupt_button.stopping": {"Stopping", "正在停止"},
|
||||
"interrupt_button.confirm": {"Stop the agent?", "停止智能体?"},
|
||||
"interrupt_button.detail": {"Work in progress will be lost.", "进行中的工作将会丢失。"},
|
||||
"interrupt_button.keep": {"Keep running", "继续运行"},
|
||||
|
||||
"token_usage_chart.label": {"Token usage", "Token 用量"},
|
||||
"token_usage_chart.input": {"Input", "输入"},
|
||||
"token_usage_chart.output": {"Output", "输出"},
|
||||
|
||||
"cost_breakdown.model": {"Model", "模型"},
|
||||
"cost_breakdown.tool": {"Tool", "工具"},
|
||||
"cost_breakdown.step": {"Step", "步骤"},
|
||||
"cost_breakdown.calls": {"Calls", "调用"},
|
||||
"cost_breakdown.input": {"Input tokens", "输入 token"},
|
||||
"cost_breakdown.output": {"Output tokens", "输出 token"},
|
||||
"cost_breakdown.cost": {"Cost", "费用"},
|
||||
"cost_breakdown.empty": {"No costs yet", "暂无费用"},
|
||||
"cost_breakdown.unnamed": {"(unnamed)", "(未命名)"},
|
||||
"cost_breakdown.total": {"Total", "合计"},
|
||||
"cost_breakdown.total_name": {"Total %s over %d calls", "合计 %s,共 %d 次调用"},
|
||||
"cost_breakdown.tokens": {"%d in · %d out", "输入 %d · 输出 %d"},
|
||||
|
||||
"trace_viewer.span": {"Span", "跨度"},
|
||||
"trace_viewer.duration": {"Duration", "耗时"},
|
||||
"trace_viewer.timeline": {"Timeline", "时间轴"},
|
||||
"trace_viewer.start": {"Start", "开始"},
|
||||
"trace_viewer.failed": {"failed", "失败"},
|
||||
"trace_viewer.empty": {"No spans", "没有跨度"},
|
||||
|
||||
"eval_result_table.case": {"Case", "用例"},
|
||||
"eval_result_table.score": {"Score", "得分"},
|
||||
"eval_result_table.result": {"Result", "结果"},
|
||||
"eval_result_table.pass": {"Pass", "通过"},
|
||||
"eval_result_table.fail": {"Fail", "失败"},
|
||||
"eval_result_table.diff": {"View differences for %s", "查看 %s 的差异"},
|
||||
"eval_result_table.view": {"View differences", "查看差异"},
|
||||
"eval_result_table.summary": {"%d of %d passed", "通过 %d / %d"},
|
||||
"eval_result_table.empty": {"No eval cases", "没有评测用例"},
|
||||
|
||||
"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", "跳过"},
|
||||
|
||||
"blink_highlight.up": {"up", "上升"},
|
||||
"blink_highlight.down": {"down", "下降"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// OrbitLoader moves a diamond around a ring of small diamonds, in Spinner's language.
|
||||
func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.36
|
||||
at := func(turn float64) (float32, float32) {
|
||||
a := 2*math.Pi*turn - math.Pi/2
|
||||
return internal.Snap(p, cx+rad*float32(math.Cos(a))), internal.Snap(p, cy+rad*float32(math.Sin(a)))
|
||||
}
|
||||
for i := range 8 {
|
||||
x, y := at(float64(i) / 8)
|
||||
p.FillPath(internal.Diamond(x, y, r.W*0.12), col.Alpha(0.3))
|
||||
}
|
||||
x, y := at(t)
|
||||
p.FillPath(internal.Diamond(x, y, internal.Snap(p, r.W*0.28)), col)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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 of kind comes from and goes to.
|
||||
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 is true; it enters and exits with motion, removed once the exit ends.
|
||||
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 {
|
||||
return
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch).Key("presence")
|
||||
box.Transition(ui.ElementTransition{Duration: d, Enter: m, Exit: m})
|
||||
box.Children(content)
|
||||
})
|
||||
return holder
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// PulseLoader shows one dot breathing between 60% and full size; never a halo.
|
||||
func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
s, a := float32(1), float32(1)
|
||||
if !still {
|
||||
w := loaderWave(t)
|
||||
s, a = 0.6+0.4*w, 0.55+0.45*w
|
||||
}
|
||||
var dot ui.Path
|
||||
dot.Circle(r.X+r.W/2, r.Y+r.H/2, internal.Snap(p, r.H*0.4*s))
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ReorderTransitionOptions configure a ReorderTransition.
|
||||
type ReorderTransitionOptions struct {
|
||||
// Duration of a move; zero means 200 ms.
|
||||
Duration time.Duration
|
||||
// Gap between items in DIPs.
|
||||
Gap float32
|
||||
}
|
||||
|
||||
// ReorderTransition lays out keyed items in a column; when their order changes they slide to their new places.
|
||||
// Items added fade in and items removed fade out.
|
||||
func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element {
|
||||
if opts.Duration < 0 || opts.Gap < 0 {
|
||||
panic("mujica: ReorderTransition Duration and Gap must not be negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = 200 * time.Millisecond
|
||||
}
|
||||
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: d, Position: true, Enter: &ui.Motion{}, Exit: &ui.Motion{}}
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(opts.Gap)
|
||||
col.Children(func() {
|
||||
for _, k := range keys {
|
||||
ui.Column(c).Key(k).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) })
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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 shows a sweeping placeholder while loading, then builds content instead.
|
||||
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 := Tokens(c)
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = Density(c).ControlHeight()
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "loading")
|
||||
}
|
||||
fill := k.SurfaceHover
|
||||
if k.Dark {
|
||||
fill = k.Border
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
e := ui.Column(c).Height(h).AlignItems(ui.Stretch).Role(ui.RoleProgress).Label(label)
|
||||
if opts.Width > 0 {
|
||||
e.Width(opts.Width)
|
||||
} else {
|
||||
e.FillWidth()
|
||||
}
|
||||
// Keyed by motion so a flip repaints.
|
||||
e.Children(func() {
|
||||
ui.Box(c).Key(still).Grow(1).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, fill, theme.CardRadius)
|
||||
if still {
|
||||
return
|
||||
}
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1600) / 1600
|
||||
band := max(r.W*0.25, 24)
|
||||
x := internal.Snap(p, r.X-band+(r.W+band)*t)
|
||||
p.Clip(r, theme.CardRadius, func() {
|
||||
p.Fill(ui.Rect{X: x, Y: r.Y, W: band, H: r.H}, k.Surface.Alpha(0.55), 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ShimmerTextOptions configure a ShimmerText.
|
||||
type ShimmerTextOptions struct {
|
||||
// Active sweeps the light; false shows plain muted text.
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ShimmerText shows a status such as "Thinking" with light sweeping across its letters.
|
||||
func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element {
|
||||
if text == "" {
|
||||
panic("mujica: ShimmerText needs text")
|
||||
}
|
||||
k := Tokens(c)
|
||||
still := c.Preferences().ReduceMotion
|
||||
if !opts.Active || still {
|
||||
e := ui.Text(c, text).TextColor(k.TextMuted)
|
||||
if opts.Active {
|
||||
e.Role(ui.RoleStatus)
|
||||
}
|
||||
return e
|
||||
}
|
||||
runes := []rune(text)
|
||||
size := c.Theme().FontSize
|
||||
// The text keeps its layout and name; the sweep is painted over it.
|
||||
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-abs32(float32(i)-at)/3)
|
||||
spans[i] = ui.Span{Text: string(ch), Size: size, Color: k.TextMuted.Mix(k.Text, d)}
|
||||
}
|
||||
p.RichText(r.X, r.Y, 0, spans...)
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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
|
||||
shown int
|
||||
done bool
|
||||
}
|
||||
|
||||
// Stagger builds n items, each entering after the one before, once when it first appears.
|
||||
// Items added after the sequence ends appear with their own entry.
|
||||
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
|
||||
upto := n
|
||||
if !s.done && !c.Preferences().ReduceMotion {
|
||||
upto = min(n, 1+int(c.Now().Sub(s.born)/every))
|
||||
if upto < n {
|
||||
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: d, Enter: enter}).Children(func() { item(i) })
|
||||
}
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// 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; an empty Label uses the built-in name.
|
||||
Frame ChartFrame
|
||||
// Line draws input and output as lines instead of stacked bars.
|
||||
Line bool
|
||||
}
|
||||
|
||||
type tokenUsageSeries struct {
|
||||
src *TokenUsage
|
||||
n int
|
||||
labels []string
|
||||
series []ChartSeries
|
||||
}
|
||||
|
||||
// TokenUsageChart shows input and output tokens per session or step, as stacked bars or lines.
|
||||
func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) ChartResult {
|
||||
if opts.Frame.Label == "" {
|
||||
opts.Frame.Label = 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) {
|
||||
in, out := make([]float64, len(usage)), make([]float64, len(usage))
|
||||
s.labels = make([]string, len(usage))
|
||||
for i, u := range usage {
|
||||
if u.Input < 0 || u.Output < 0 {
|
||||
panic("mujica: TokenUsage counts must not be negative")
|
||||
}
|
||||
in[i], out[i], s.labels[i] = float64(u.Input), float64(u.Output), u.Label
|
||||
}
|
||||
s.series = []ChartSeries{{Name: msg(c, "token_usage_chart.input"), Values: in}, {Name: msg(c, "token_usage_chart.output"), Values: out}}
|
||||
s.src, s.n = first, len(usage)
|
||||
}
|
||||
format := func(v float64) string { return strconv.FormatFloat(v, 'f', 0, 64) }
|
||||
var res ChartResult
|
||||
holder.Children(func() {
|
||||
if opts.Line {
|
||||
res = LineChart(c, s.series, LineChartOptions{Frame: opts.Frame, Format: format})
|
||||
return
|
||||
}
|
||||
res = BarChart(c, s.labels, s.series, BarChartOptions{Frame: opts.Frame, Stacked: true, Format: format})
|
||||
})
|
||||
res.Element = holder
|
||||
return res
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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 tool an agent may call; the caller keeps Enabled.
|
||||
type AgentTool struct {
|
||||
Name string
|
||||
Description string
|
||||
Enabled bool
|
||||
Params []ToolParam
|
||||
}
|
||||
|
||||
// ToolRegistryPanelOptions configure a ToolRegistryPanel.
|
||||
type ToolRegistryPanelOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ToolRegistryResult is a built ToolRegistryPanel and its events.
|
||||
type ToolRegistryResult struct {
|
||||
Element *ui.Element
|
||||
toggled string
|
||||
on bool
|
||||
}
|
||||
|
||||
// Toggled returns the tool switched this frame and its new state.
|
||||
func (r ToolRegistryResult) Toggled() (name string, on, ok bool) {
|
||||
return r.toggled, r.on, r.toggled != ""
|
||||
}
|
||||
|
||||
// ToolRegistryPanel lists tools with on/off switches and previews the chosen tool's parameters.
|
||||
func ToolRegistryPanel(c *ui.Context, s *ListState[string], tools []AgentTool, opts ToolRegistryPanelOptions) ToolRegistryResult {
|
||||
seen := map[string]bool{}
|
||||
for _, t := range tools {
|
||||
if t.Name == "" || seen[t.Name] {
|
||||
panic("mujica: AgentTool needs a unique Name")
|
||||
}
|
||||
seen[t.Name] = true
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Stretch).Gap(u * 3).Disabled(opts.Disabled)
|
||||
res := ToolRegistryResult{Element: row}
|
||||
row.Children(func() {
|
||||
ui.Column(c).Width(300).Shrink(0).AlignItems(ui.Stretch).Children(func() {
|
||||
List(c, s, len(tools), ListOptions[string]{
|
||||
Key: func(i int) string { return tools[i].Name }, Label: func(i int) string { return tools[i].Name },
|
||||
Empty: 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(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||
})
|
||||
on := t.Enabled
|
||||
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)
|
||||
}
|
||||
}).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()
|
||||
var tool *AgentTool
|
||||
for i := range tools {
|
||||
if ok && tools[i].Name == name {
|
||||
tool = &tools[i]
|
||||
}
|
||||
}
|
||||
if tool == nil {
|
||||
ui.Text(c, msg(c, "tool_registry_panel.choose")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
toolSchema(c, *tool)
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// toolSchema previews a tool's parameters as a typed list.
|
||||
func toolSchema(c *ui.Context, t AgentTool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
small := FontSize(c, theme.CaptionSize)
|
||||
ui.Text(c, t.Name).Font(theme.SerifFont).FontWeight(600).FontSize(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, msg(c, "tool_registry_panel.no_params")).TextColor(k.TextMuted)
|
||||
return
|
||||
}
|
||||
for _, p := range t.Params {
|
||||
ui.Column(c).Gap(2).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, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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") })
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TraceAttr is a key and value recorded on a span.
|
||||
type TraceAttr struct{ Key, Value string }
|
||||
|
||||
// TraceSpan is one timed call of a trace; Start and End are offsets from the trace start.
|
||||
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 TreeTableState[string]
|
||||
index traceIndex
|
||||
src *TraceSpan
|
||||
n int
|
||||
built bool
|
||||
}
|
||||
|
||||
type traceIndex struct {
|
||||
byID map[string]int
|
||||
kids map[string][]string
|
||||
roots []string
|
||||
end time.Duration
|
||||
}
|
||||
|
||||
// TraceViewerOptions configure a TraceViewer.
|
||||
type TraceViewerOptions struct {
|
||||
Status DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// TraceViewer shows a call tree with each span's time bar; the chosen span's attributes show below.
|
||||
func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts TraceViewerOptions) DataTableView {
|
||||
var first *TraceSpan
|
||||
if len(spans) > 0 {
|
||||
first = &spans[0]
|
||||
}
|
||||
if !s.built || s.src != first || s.n != len(spans) {
|
||||
s.index = traceBuild(spans)
|
||||
s.src, s.n, s.built = first, len(spans), true
|
||||
for _, id := range s.index.roots {
|
||||
s.Tree.SetOpen(id, true)
|
||||
}
|
||||
slog.Debug("mujica: trace viewer index", "spans", len(spans), "roots", len(s.index.roots))
|
||||
}
|
||||
ix := s.index
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
span := func(id string) TraceSpan { return spans[ix.byID[id]] }
|
||||
cols := []TreeColumn[string]{
|
||||
{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"))
|
||||
}
|
||||
}},
|
||||
{ID: "duration", Title: 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: 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 DataTableView
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
col.Children(func() {
|
||||
v = TreeTable(c, &s.Tree, 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: msg(c, "trace_viewer.empty")})
|
||||
v.Element.Grow(1)
|
||||
id, ok := s.Tree.Selected()
|
||||
if _, known := ix.byID[id]; !ok || !known {
|
||||
return
|
||||
}
|
||||
traceAttrs(c, span(id))
|
||||
})
|
||||
v.Element = col
|
||||
return v
|
||||
}
|
||||
|
||||
// traceAttrs lists the chosen span's timing and attributes.
|
||||
func traceAttrs(c *ui.Context, sp TraceSpan) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
ui.Column(c).Gap(u).Padding(u*3, u*4).Background(k.Background).Radius(theme.CardRadius).Shrink(0).Children(func() {
|
||||
ui.Text(c, sp.Name).Font(theme.SerifFont).FontWeight(600)
|
||||
row := func(key, val string) {
|
||||
ui.Row(c).Gap(u * 3).Children(func() {
|
||||
ui.Text(c, key).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Width(140).Shrink(0)
|
||||
ui.Text(c, val).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).Grow(1).MinWidth(0)
|
||||
})
|
||||
}
|
||||
row(msg(c, "trace_viewer.start"), traceDuration(sp.Start))
|
||||
row(msg(c, "trace_viewer.duration"), traceDuration(sp.End-sp.Start))
|
||||
for _, a := range sp.Attrs {
|
||||
row(a.Key, a.Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// traceBuild indexes spans by ID and parent; 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 && sp.Parent != sp.ID {
|
||||
ix.kids[sp.Parent] = append(ix.kids[sp.Parent], sp.ID)
|
||||
} else {
|
||||
ix.roots = append(ix.roots, sp.ID)
|
||||
}
|
||||
}
|
||||
seen := 0
|
||||
var walk func(ids []string)
|
||||
walk = func(ids []string) {
|
||||
for _, id := range ids {
|
||||
seen++
|
||||
walk(ix.kids[id])
|
||||
}
|
||||
}
|
||||
walk(ix.roots)
|
||||
if seen != len(spans) {
|
||||
panic("mujica: TraceSpan parents form a cycle")
|
||||
}
|
||||
return ix
|
||||
}
|
||||
|
||||
// traceDuration prints microseconds, milliseconds or seconds.
|
||||
func traceDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Millisecond:
|
||||
return fmt.Sprintf("%d µs", d.Microseconds())
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%.1f ms", float64(d)/float64(time.Millisecond))
|
||||
}
|
||||
return fmt.Sprintf("%.2f s", d.Seconds())
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// TypewriterOptions configure a Typewriter.
|
||||
type TypewriterOptions struct {
|
||||
// Speed is characters per second; zero means 40.
|
||||
Speed float64
|
||||
// Skippable adds a Skip button that shows the rest at once.
|
||||
Skippable bool
|
||||
}
|
||||
|
||||
// TypewriterResult is a built Typewriter and its events.
|
||||
type TypewriterResult struct {
|
||||
Element *ui.Element
|
||||
done bool
|
||||
}
|
||||
|
||||
// Finished reports the frame the whole text came into view.
|
||||
func (r TypewriterResult) Finished() bool { return r.done }
|
||||
|
||||
type typewriterState struct {
|
||||
text []rune
|
||||
shown int
|
||||
last time.Time
|
||||
skipped bool
|
||||
done bool
|
||||
}
|
||||
|
||||
// Typewriter reveals text a character at a time; text that grows continues where it was.
|
||||
func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterResult {
|
||||
speed := opts.Speed
|
||||
if speed == 0 {
|
||||
speed = 40
|
||||
}
|
||||
if speed < 0 {
|
||||
panic("mujica: Typewriter Speed is negative")
|
||||
}
|
||||
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
|
||||
runes := []rune(text)
|
||||
if !strings.HasPrefix(text, string(s.text)) {
|
||||
s.shown, s.skipped, s.done = 0, false, false
|
||||
slog.Debug("mujica: typewriter restart")
|
||||
}
|
||||
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()
|
||||
if c.Preferences().ReduceMotion || s.skipped {
|
||||
s.shown = len(runes)
|
||||
} else if s.shown < len(runes) {
|
||||
step := time.Duration(float64(time.Second) / speed)
|
||||
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) {
|
||||
c.After(s.last.Add(step).Sub(now))
|
||||
}
|
||||
} else {
|
||||
s.last = now
|
||||
}
|
||||
col.Children(func() {
|
||||
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)
|
||||
slog.Debug("mujica: typewriter skip")
|
||||
}
|
||||
}
|
||||
})
|
||||
if s.shown == len(runes) && !s.done {
|
||||
s.done, res.done = true, true
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// WaveLoader draws a sine line flowing to the right.
|
||||
func WaveLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 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)
|
||||
})
|
||||
}
|
||||