diff --git a/bars_loader.go b/bars_loader.go new file mode 100644 index 0000000..fbdab80 --- /dev/null +++ b/bars_loader.go @@ -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) + } + }) +} diff --git a/blink_highlight.go b/blink_highlight.go new file mode 100644 index 0000000..31b351b --- /dev/null +++ b/blink_highlight.go @@ -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 +} diff --git a/blink_highlight_test.go b/blink_highlight_test.go new file mode 100644 index 0000000..ebc9e59 --- /dev/null +++ b/blink_highlight_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestBlinkHighlightFlashesOnceWithDirection(t *testing.T) { + value := 10.0 + tt, n := motionFrames(func(c *ui.Context) { + BlinkHighlight(c, value, BlinkHighlightOptions{Duration: 200 * time.Millisecond}) + }, 200, 60) + if motionBusy(tt, n) { + t.Fatal("first value flashed") + } + if _, ok := tt.Find("10.00"); !ok { + t.Fatal("value not named") + } + value = 12 + tt.Frame() + a := encode(t, tt.Image()) + if _, ok := tt.Find("12.00, up"); !ok { + t.Fatalf("direction not named: %q", tt.Texts()) + } + time.Sleep(250 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) || motionBusy(tt, n) { + t.Fatal("flash did not fade and stop") + } + value = 9 + tt.Frame() + if _, ok := tt.Find("9.00, down"); !ok { + t.Fatalf("down not named: %q", tt.Texts()) + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + value = 11 + tt.Frame() + if motionBusy(tt, n) { + t.Fatal("flash under reduced motion") + } + if _, ok := tt.Find("11.00, up"); !ok { + t.Fatal("static mark missing under reduced motion") + } +} + +func TestBlinkHighlightInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + BlinkHighlight(c, 1, BlinkHighlightOptions{Duration: -1}) + }, 100, 50, 1) +} + +func TestBlinkHighlightSnapshot(t *testing.T) { + snapshotAfter(t, "blink_highlight", 300, 100, func(c *ui.Context) { + v := ui.Local(c.Root(), "v", func() float64 { return 101.5 }) + w := ui.Local(c.Root(), "w", func() float64 { return 88.25 }) + ui.Column(c).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() { + BlinkHighlight(c, *v, BlinkHighlightOptions{}) + BlinkHighlight(c, *w, BlinkHighlightOptions{}) + }) + *v, *w = 103.75, 86.5 + }, func(tt *ui.Tester) { tt.Frame() }) +} diff --git a/cost_breakdown.go b/cost_breakdown.go new file mode 100644 index 0000000..7b88a78 --- /dev/null +++ b/cost_breakdown.go @@ -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 +} diff --git a/cost_breakdown_test.go b/cost_breakdown_test.go new file mode 100644 index 0000000..0445900 --- /dev/null +++ b/cost_breakdown_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func costSample() []CostItem { + return []CostItem{ + {Model: "opus", Tool: "search", Step: "plan", InputTokens: 1000, OutputTokens: 200, Cost: 0.30}, + {Model: "haiku", Tool: "search", Step: "gather", InputTokens: 4000, OutputTokens: 500, Cost: 0.05}, + {Model: "opus", Tool: "write", Step: "write", InputTokens: 2000, OutputTokens: 3000, Cost: 1.15}, + } +} + +func TestCostSum(t *testing.T) { + rows, total := costSum(costSample(), CostByModel, "?") + if len(rows) != 2 || rows[0].name != "opus" || rows[0].calls != 2 || rows[0].input != 3000 || total.calls != 3 { + t.Fatalf("rows %+v total %+v", rows, total) + } + if d := total.cost - 1.5; d > 1e-9 || d < -1e-9 { + t.Fatalf("total cost %v", total.cost) + } + rows, _ = costSum([]CostItem{{Cost: 1}}, CostByTool, "(none)") + if rows[0].name != "(none)" { + t.Fatalf("unnamed group %q", rows[0].name) + } +} + +func TestCostBreakdownGroupsAndTotal(t *testing.T) { + var s CostBreakdownState + by := CostByModel + items := costSample() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(400).Children(func() { + CostBreakdown(c, &s, items, CostBreakdownOptions{GroupBy: by}).Element.Grow(1) + }) + }, 800, 420) + for _, want := range []string{"opus", "haiku", "$1.45", "$0.05", "97%", "Total", "$1.50", "7000 in · 3700 out"} { + if !tt.HasText(want) { + t.Errorf("missing %q in %q", want, tt.Texts()) + } + } + if _, ok := tt.Find("Total $1.50 over 3 calls"); !ok { + t.Error("total not named") + } + rows := &s.rows[0] + tt.Frame() + if &s.rows[0] != rows { + t.Fatal("sums recomputed without a change") + } + by = CostByStep + tt.Frame() + if !tt.HasText("gather") || tt.HasText("haiku") { + t.Fatalf("group by step: %q", tt.Texts()) + } + if err := tt.Click("Cost"); err != nil { + t.Fatal(err) + } + if s.Table.Sort.Column != "cost" { + t.Fatalf("sort %+v", s.Table.Sort) + } +} + +func TestCostBreakdownInvalid(t *testing.T) { + for _, tc := range []struct { + items []CostItem + opts CostBreakdownOptions + }{{[]CostItem{{Cost: -1}}, CostBreakdownOptions{}}, {nil, CostBreakdownOptions{GroupBy: 7}}, {nil, CostBreakdownOptions{Decimals: -1}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", tc) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var s CostBreakdownState + CostBreakdown(c, &s, tc.items, tc.opts) + }, 300, 200, 1) + }() + } +} + +func TestCostBreakdownSnapshot(t *testing.T) { + items := costSample() + snapshot(t, "cost_breakdown", 760, 300, func(c *ui.Context) { + s := ui.Local(c.Root(), "s", func() *CostBreakdownState { return &CostBreakdownState{} }) + ui.Column(c).Padding(16).Height(300).Children(func() { + CostBreakdown(c, *s, items, CostBreakdownOptions{}).Element.Grow(1) + }) + }) +} diff --git a/count_up.go b/count_up.go new file mode 100644 index 0000000..71b75be --- /dev/null +++ b/count_up.go @@ -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() }) +} diff --git a/count_up_test.go b/count_up_test.go new file mode 100644 index 0000000..a5e2bc9 --- /dev/null +++ b/count_up_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCountUpCountsAndSettles(t *testing.T) { + value := 100.0 + tt, n := motionFrames(func(c *ui.Context) { + CountUp(c, value, CountUpOptions{Duration: 300 * time.Millisecond}) + }, 200, 60) + if !tt.HasText("100") || motionBusy(tt, n) { + t.Fatalf("first value shown at once: %q", tt.Texts()) + } + value = 200 + tt.Frame() + time.Sleep(100 * time.Millisecond) + tt.Frame() + mid := "" + for _, s := range tt.Texts() { + if s != "200" { + mid = s + } + } + if mid == "" || mid == "100" { + t.Fatalf("no value between: %q", tt.Texts()) + } + time.Sleep(300 * time.Millisecond) + tt.Frame() + if motionCount(tt, "200") != 2 || motionBusy(tt, n) { + t.Fatalf("did not settle: %q", tt.Texts()) + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + value = 5 + tt.Frame() + if motionCount(tt, "5") != 2 { + t.Fatalf("reduced motion did not jump: %q", tt.Texts()) + } +} + +func TestCountUpFormatAndInvalid(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CountUp(c, 12.345, CountUpOptions{Decimals: 1}) + CountUp(c, 3, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.0f items", v) }}) + }, 300, 80) + if !tt.HasText("12.3") || !tt.HasText("3 items") { + t.Fatalf("format: %q", tt.Texts()) + } + for _, o := range []CountUpOptions{{Duration: -1}, {Decimals: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", o) + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); CountUp(c, 1, o) }, 100, 50, 1) + }() + } + defer func() { + if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "finite") { + t.Fatal("NaN did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); CountUp(c, nanValue(), CountUpOptions{}) }, 100, 50, 1) +} + +func nanValue() float64 { + zero := 0.0 + return zero / zero +} + +func TestCountUpSnapshot(t *testing.T) { + snapshot(t, "count_up", 300, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + CountUp(c, 12840, CountUpOptions{}).FontSize(FontSize(c, 28)).Font("Georgia, serif") + CountUp(c, 0.875, CountUpOptions{Format: func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }}) + }) + }) +} diff --git a/dots_loader.go b/dots_loader.go new file mode 100644 index 0000000..b1cbb8a --- /dev/null +++ b/dots_loader.go @@ -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)) + } + }) +} diff --git a/eval_result_table.go b/eval_result_table.go new file mode 100644 index 0000000..915e0c7 --- /dev/null +++ b/eval_result_table.go @@ -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 +} diff --git a/eval_result_table_test.go b/eval_result_table_test.go new file mode 100644 index 0000000..80051fd --- /dev/null +++ b/eval_result_table_test.go @@ -0,0 +1,74 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func evalSample() []EvalCase { + return []EvalCase{ + {ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, + {ID: "c2", Name: "Refuses forgery", Score: 0.41}, + {ID: "c3", Name: "Summarizes letter", Score: 0.87, Passed: true}, + } +} + +func TestEvalResultTableSummaryAndDiff(t *testing.T) { + var s DataTableState[string] + diff := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(400).Children(func() { + r := EvalResultTable(c, &s, evalSample(), EvalResultTableOptions{}) + r.Table.Element.Grow(1) + if id, ok := r.Diff(); ok { + diff = id + } + }) + }, 700, 420) + for _, want := range []string{"2 of 3 passed", "0.41", "Fail", "Pass"} { + if !tt.HasText(want) { + t.Errorf("missing %q: %q", want, tt.Texts()) + } + } + if err := tt.Click("View differences for Refuses forgery"); err != nil { + t.Fatal(err) + } + if diff != "c2" { + t.Fatalf("diff %q", diff) + } + if err := tt.Click("Score"); err != nil { + t.Fatal(err) + } + if s.Sort.Column != "score" { + t.Fatalf("sort %+v", s.Sort) + } +} + +func TestEvalResultTableInvalid(t *testing.T) { + for _, cases := range [][]EvalCase{{{Name: "x"}}, {{ID: "a", Score: nanValue()}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v did not panic", cases) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var s DataTableState[string] + EvalResultTable(c, &s, cases, EvalResultTableOptions{}) + }, 300, 200, 1) + }() + } +} + +func TestEvalResultTableSnapshot(t *testing.T) { + cases := evalSample() + snapshot(t, "eval_result_table", 640, 300, func(c *ui.Context) { + s := ui.Local(c.Root(), "s", func() *DataTableState[string] { return &DataTableState[string]{} }) + ui.Column(c).Padding(16).Height(300).Children(func() { + EvalResultTable(c, *s, cases, EvalResultTableOptions{}).Table.Element.Grow(1) + }) + }) +} diff --git a/interrupt_button.go b/interrupt_button.go new file mode 100644 index 0000000..fea58a9 --- /dev/null +++ b/interrupt_button.go @@ -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 +} diff --git a/interrupt_button_test.go b/interrupt_button_test.go new file mode 100644 index 0000000..d7cd259 --- /dev/null +++ b/interrupt_button_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestInterruptButtonDirectAndConfirmed(t *testing.T) { + running, interrupting, confirm := true, false, false + stops := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(20).Children(func() { + if InterruptButton(c, running, InterruptButtonOptions{Confirm: confirm, Interrupting: interrupting}).Changed() { + stops++ + } + }) + }, 400, 300) + if err := tt.Click("Stop"); err != nil { + t.Fatal(err) + } + if stops != 1 { + t.Fatalf("direct stop %d", stops) + } + confirm = true + tt.Frame() + _ = tt.Click("Stop") + if stops != 1 || !tt.HasText("Stop the agent?") { + t.Fatalf("confirm not asked: %q", tt.Texts()) + } + _ = tt.Click("Keep running") + if stops != 1 || tt.HasText("Stop the agent?") { + t.Fatal("cancel interrupted") + } + _ = tt.Click("Stop") + tt.Key(0, ui.KeyTab) // Danger focuses Cancel first; Tab reaches Stop + tt.Key(0, ui.KeyEnter) + if stops != 2 { + t.Fatalf("confirmed stop %d: %q", stops, tt.Texts()) + } + interrupting = true + tt.Frame() + _ = tt.Click("Stopping") + if stops != 2 || !tt.HasText("Stopping") { + t.Fatalf("interrupting: stops %d %q", stops, tt.Texts()) + } + interrupting, running = false, false + tt.Frame() + _ = tt.Click("Stop") + if stops != 2 { + t.Fatal("idle button interrupted") + } +} + +func TestInterruptButtonInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + InterruptButton(c, false, InterruptButtonOptions{Interrupting: true}) + }, 100, 50, 1) +} + +func TestInterruptButtonSnapshot(t *testing.T) { + snapshot(t, "interrupt_button", 460, 90, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(12).Children(func() { + InterruptButton(c, true, InterruptButtonOptions{}) + InterruptButton(c, true, InterruptButtonOptions{Interrupting: true}) + InterruptButton(c, false, InterruptButtonOptions{Label: "Cancel run"}) + }) + }) +} diff --git a/layout_transition.go b/layout_transition.go new file mode 100644 index 0000000..3567d07 --- /dev/null +++ b/layout_transition.go @@ -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 +} diff --git a/loaders_test.go b/loaders_test.go new file mode 100644 index 0000000..0761011 --- /dev/null +++ b/loaders_test.go @@ -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) } diff --git a/messages_motion.go b/messages_motion.go new file mode 100644 index 0000000..8597473 --- /dev/null +++ b/messages_motion.go @@ -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", "下降"}, + }) +} diff --git a/orbit_loader.go b/orbit_loader.go new file mode 100644 index 0000000..6a32103 --- /dev/null +++ b/orbit_loader.go @@ -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) + }) +} diff --git a/presence.go b/presence.go new file mode 100644 index 0000000..2eacd5a --- /dev/null +++ b/presence.go @@ -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 +} diff --git a/pulse_loader.go b/pulse_loader.go new file mode 100644 index 0000000..a500bcf --- /dev/null +++ b/pulse_loader.go @@ -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)) + }) +} diff --git a/reorder_transition.go b/reorder_transition.go new file mode 100644 index 0000000..9b27221 --- /dev/null +++ b/reorder_transition.go @@ -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 +} diff --git a/shimmer.go b/shimmer.go new file mode 100644 index 0000000..174a960 --- /dev/null +++ b/shimmer.go @@ -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 +} diff --git a/shimmer_test.go b/shimmer_test.go new file mode 100644 index 0000000..7479e93 --- /dev/null +++ b/shimmer_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestShimmerOnlyWhileLoading(t *testing.T) { + loading := true + tt, n := motionFrames(func(c *ui.Context) { + Shimmer(c, loading, ShimmerOptions{Height: 40, Label: "Loading report"}, func() { ui.Text(c, "Report ready") }) + }, 300, 100) + if _, ok := tt.Find("Loading report"); !ok || tt.HasText("Report ready") { + t.Fatalf("loading: %q", tt.Texts()) + } + a := encode(t, tt.Image()) + time.Sleep(150 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { + t.Fatal("shimmer does not sweep") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionBusy(tt, n) { + t.Fatal("shimmer repaints under reduced motion") + } + tt.SetPreferences(ui.Preferences{TextScale: 1}) + loading = false + tt.Frame() + if !tt.HasText("Report ready") || motionBusy(tt, n) { + t.Fatalf("loaded: %q", tt.Texts()) + } +} + +func TestShimmerInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Shimmer(c, true, ShimmerOptions{Width: -1}, nil) + }, 100, 50, 1) +} + +func TestShimmerSnapshot(t *testing.T) { + snapshot(t, "shimmer", 400, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + Shimmer(c, true, ShimmerOptions{}, nil) + Shimmer(c, true, ShimmerOptions{Width: 160, Height: 24}, nil) + Shimmer(c, false, ShimmerOptions{}, func() { ui.Text(c, "Loaded content") }) + }) + }) +} + +func TestShimmerTextSweepsAndStops(t *testing.T) { + active := true + tt, n := motionFrames(func(c *ui.Context) { + ShimmerText(c, "Thinking", ShimmerTextOptions{Active: active}) + }, 200, 60) + if _, ok := tt.Find("Thinking"); !ok { + t.Fatal("text not named") + } + a := encode(t, tt.Image()) + time.Sleep(200 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { + t.Fatal("no sweep") + } + active = false + tt.Frame() + if motionBusy(tt, n) || !tt.HasText("Thinking") { + t.Fatal("inactive text still sweeps") + } + active = true + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionBusy(tt, n) { + t.Fatal("sweeps under reduced motion") + } + defer func() { + if recover() == nil { + t.Fatal("empty text did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ShimmerText(c, "", ShimmerTextOptions{}) + }, 100, 50, 1) +} + +func TestShimmerTextSnapshot(t *testing.T) { + snapshot(t, "shimmer_text", 300, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + ShimmerText(c, "Thinking…", ShimmerTextOptions{Active: true}) + ShimmerText(c, "思考中…", ShimmerTextOptions{}) + }) + }) +} diff --git a/shimmer_text.go b/shimmer_text.go new file mode 100644 index 0000000..1b7b9bd --- /dev/null +++ b/shimmer_text.go @@ -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 +} diff --git a/stagger.go b/stagger.go new file mode 100644 index 0000000..1b39ab1 --- /dev/null +++ b/stagger.go @@ -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 +} diff --git a/testdata/snapshots/darwin/bars_loader-dark.png b/testdata/snapshots/darwin/bars_loader-dark.png new file mode 100644 index 0000000..96707b3 Binary files /dev/null and b/testdata/snapshots/darwin/bars_loader-dark.png differ diff --git a/testdata/snapshots/darwin/bars_loader-light.png b/testdata/snapshots/darwin/bars_loader-light.png new file mode 100644 index 0000000..dbe7c19 Binary files /dev/null and b/testdata/snapshots/darwin/bars_loader-light.png differ diff --git a/testdata/snapshots/darwin/blink_highlight-dark.png b/testdata/snapshots/darwin/blink_highlight-dark.png new file mode 100644 index 0000000..c2c3624 Binary files /dev/null and b/testdata/snapshots/darwin/blink_highlight-dark.png differ diff --git a/testdata/snapshots/darwin/blink_highlight-light.png b/testdata/snapshots/darwin/blink_highlight-light.png new file mode 100644 index 0000000..8b68578 Binary files /dev/null and b/testdata/snapshots/darwin/blink_highlight-light.png differ diff --git a/testdata/snapshots/darwin/cost_breakdown-dark.png b/testdata/snapshots/darwin/cost_breakdown-dark.png new file mode 100644 index 0000000..7cbd2e4 Binary files /dev/null and b/testdata/snapshots/darwin/cost_breakdown-dark.png differ diff --git a/testdata/snapshots/darwin/cost_breakdown-light.png b/testdata/snapshots/darwin/cost_breakdown-light.png new file mode 100644 index 0000000..2313ff7 Binary files /dev/null and b/testdata/snapshots/darwin/cost_breakdown-light.png differ diff --git a/testdata/snapshots/darwin/count_up-dark.png b/testdata/snapshots/darwin/count_up-dark.png new file mode 100644 index 0000000..aecbad5 Binary files /dev/null and b/testdata/snapshots/darwin/count_up-dark.png differ diff --git a/testdata/snapshots/darwin/count_up-light.png b/testdata/snapshots/darwin/count_up-light.png new file mode 100644 index 0000000..f04253c Binary files /dev/null and b/testdata/snapshots/darwin/count_up-light.png differ diff --git a/testdata/snapshots/darwin/dots_loader-dark.png b/testdata/snapshots/darwin/dots_loader-dark.png new file mode 100644 index 0000000..bb32f30 Binary files /dev/null and b/testdata/snapshots/darwin/dots_loader-dark.png differ diff --git a/testdata/snapshots/darwin/dots_loader-light.png b/testdata/snapshots/darwin/dots_loader-light.png new file mode 100644 index 0000000..c2c3c13 Binary files /dev/null and b/testdata/snapshots/darwin/dots_loader-light.png differ diff --git a/testdata/snapshots/darwin/eval_result_table-dark.png b/testdata/snapshots/darwin/eval_result_table-dark.png new file mode 100644 index 0000000..6a3fdf0 Binary files /dev/null and b/testdata/snapshots/darwin/eval_result_table-dark.png differ diff --git a/testdata/snapshots/darwin/eval_result_table-light.png b/testdata/snapshots/darwin/eval_result_table-light.png new file mode 100644 index 0000000..96502fd Binary files /dev/null and b/testdata/snapshots/darwin/eval_result_table-light.png differ diff --git a/testdata/snapshots/darwin/interrupt_button-dark.png b/testdata/snapshots/darwin/interrupt_button-dark.png new file mode 100644 index 0000000..6a1ddf4 Binary files /dev/null and b/testdata/snapshots/darwin/interrupt_button-dark.png differ diff --git a/testdata/snapshots/darwin/interrupt_button-light.png b/testdata/snapshots/darwin/interrupt_button-light.png new file mode 100644 index 0000000..a4f5601 Binary files /dev/null and b/testdata/snapshots/darwin/interrupt_button-light.png differ diff --git a/testdata/snapshots/darwin/layout_transition-dark.png b/testdata/snapshots/darwin/layout_transition-dark.png new file mode 100644 index 0000000..c38afe9 Binary files /dev/null and b/testdata/snapshots/darwin/layout_transition-dark.png differ diff --git a/testdata/snapshots/darwin/layout_transition-light.png b/testdata/snapshots/darwin/layout_transition-light.png new file mode 100644 index 0000000..b0f6b3e Binary files /dev/null and b/testdata/snapshots/darwin/layout_transition-light.png differ diff --git a/testdata/snapshots/darwin/orbit_loader-dark.png b/testdata/snapshots/darwin/orbit_loader-dark.png new file mode 100644 index 0000000..f80e876 Binary files /dev/null and b/testdata/snapshots/darwin/orbit_loader-dark.png differ diff --git a/testdata/snapshots/darwin/orbit_loader-light.png b/testdata/snapshots/darwin/orbit_loader-light.png new file mode 100644 index 0000000..fdf0dce Binary files /dev/null and b/testdata/snapshots/darwin/orbit_loader-light.png differ diff --git a/testdata/snapshots/darwin/presence-dark.png b/testdata/snapshots/darwin/presence-dark.png new file mode 100644 index 0000000..dd58ee3 Binary files /dev/null and b/testdata/snapshots/darwin/presence-dark.png differ diff --git a/testdata/snapshots/darwin/presence-light.png b/testdata/snapshots/darwin/presence-light.png new file mode 100644 index 0000000..0f1422d Binary files /dev/null and b/testdata/snapshots/darwin/presence-light.png differ diff --git a/testdata/snapshots/darwin/pulse_loader-dark.png b/testdata/snapshots/darwin/pulse_loader-dark.png new file mode 100644 index 0000000..715cc24 Binary files /dev/null and b/testdata/snapshots/darwin/pulse_loader-dark.png differ diff --git a/testdata/snapshots/darwin/pulse_loader-light.png b/testdata/snapshots/darwin/pulse_loader-light.png new file mode 100644 index 0000000..c971922 Binary files /dev/null and b/testdata/snapshots/darwin/pulse_loader-light.png differ diff --git a/testdata/snapshots/darwin/reorder_transition-dark.png b/testdata/snapshots/darwin/reorder_transition-dark.png new file mode 100644 index 0000000..888af78 Binary files /dev/null and b/testdata/snapshots/darwin/reorder_transition-dark.png differ diff --git a/testdata/snapshots/darwin/reorder_transition-light.png b/testdata/snapshots/darwin/reorder_transition-light.png new file mode 100644 index 0000000..466fc25 Binary files /dev/null and b/testdata/snapshots/darwin/reorder_transition-light.png differ diff --git a/testdata/snapshots/darwin/shimmer-dark.png b/testdata/snapshots/darwin/shimmer-dark.png new file mode 100644 index 0000000..524c841 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer-dark.png differ diff --git a/testdata/snapshots/darwin/shimmer-light.png b/testdata/snapshots/darwin/shimmer-light.png new file mode 100644 index 0000000..ce924ab Binary files /dev/null and b/testdata/snapshots/darwin/shimmer-light.png differ diff --git a/testdata/snapshots/darwin/shimmer_text-dark.png b/testdata/snapshots/darwin/shimmer_text-dark.png new file mode 100644 index 0000000..82d6c55 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer_text-dark.png differ diff --git a/testdata/snapshots/darwin/shimmer_text-light.png b/testdata/snapshots/darwin/shimmer_text-light.png new file mode 100644 index 0000000..06cd472 Binary files /dev/null and b/testdata/snapshots/darwin/shimmer_text-light.png differ diff --git a/testdata/snapshots/darwin/stagger-dark.png b/testdata/snapshots/darwin/stagger-dark.png new file mode 100644 index 0000000..3867f81 Binary files /dev/null and b/testdata/snapshots/darwin/stagger-dark.png differ diff --git a/testdata/snapshots/darwin/stagger-light.png b/testdata/snapshots/darwin/stagger-light.png new file mode 100644 index 0000000..ccb86b9 Binary files /dev/null and b/testdata/snapshots/darwin/stagger-light.png differ diff --git a/testdata/snapshots/darwin/token_usage_chart-dark.png b/testdata/snapshots/darwin/token_usage_chart-dark.png new file mode 100644 index 0000000..71af2ee Binary files /dev/null and b/testdata/snapshots/darwin/token_usage_chart-dark.png differ diff --git a/testdata/snapshots/darwin/token_usage_chart-light.png b/testdata/snapshots/darwin/token_usage_chart-light.png new file mode 100644 index 0000000..812efcb Binary files /dev/null and b/testdata/snapshots/darwin/token_usage_chart-light.png differ diff --git a/testdata/snapshots/darwin/tool_registry_panel-dark.png b/testdata/snapshots/darwin/tool_registry_panel-dark.png new file mode 100644 index 0000000..0dc412f Binary files /dev/null and b/testdata/snapshots/darwin/tool_registry_panel-dark.png differ diff --git a/testdata/snapshots/darwin/tool_registry_panel-light.png b/testdata/snapshots/darwin/tool_registry_panel-light.png new file mode 100644 index 0000000..8ed4916 Binary files /dev/null and b/testdata/snapshots/darwin/tool_registry_panel-light.png differ diff --git a/testdata/snapshots/darwin/trace_viewer-dark.png b/testdata/snapshots/darwin/trace_viewer-dark.png new file mode 100644 index 0000000..2846673 Binary files /dev/null and b/testdata/snapshots/darwin/trace_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/trace_viewer-light.png b/testdata/snapshots/darwin/trace_viewer-light.png new file mode 100644 index 0000000..a1f5863 Binary files /dev/null and b/testdata/snapshots/darwin/trace_viewer-light.png differ diff --git a/testdata/snapshots/darwin/typewriter-dark.png b/testdata/snapshots/darwin/typewriter-dark.png new file mode 100644 index 0000000..4573c7f Binary files /dev/null and b/testdata/snapshots/darwin/typewriter-dark.png differ diff --git a/testdata/snapshots/darwin/typewriter-light.png b/testdata/snapshots/darwin/typewriter-light.png new file mode 100644 index 0000000..ee60d53 Binary files /dev/null and b/testdata/snapshots/darwin/typewriter-light.png differ diff --git a/testdata/snapshots/darwin/wave_loader-dark.png b/testdata/snapshots/darwin/wave_loader-dark.png new file mode 100644 index 0000000..6010e38 Binary files /dev/null and b/testdata/snapshots/darwin/wave_loader-dark.png differ diff --git a/testdata/snapshots/darwin/wave_loader-light.png b/testdata/snapshots/darwin/wave_loader-light.png new file mode 100644 index 0000000..143110b Binary files /dev/null and b/testdata/snapshots/darwin/wave_loader-light.png differ diff --git a/token_usage_chart.go b/token_usage_chart.go new file mode 100644 index 0000000..11e139e --- /dev/null +++ b/token_usage_chart.go @@ -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 +} diff --git a/token_usage_chart_test.go b/token_usage_chart_test.go new file mode 100644 index 0000000..4e6d21a --- /dev/null +++ b/token_usage_chart_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func tokenUsageSample() []TokenUsage { + return []TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Search", Input: 4800, Output: 650}, {Label: "Write", Input: 2100, Output: 2900}} +} + +func TestTokenUsageChartBarsKeyboard(t *testing.T) { + usage := tokenUsageSample() + h := &chartHarness{build: func(c *ui.Context) ChartResult { return TokenUsageChart(c, usage, TokenUsageChartOptions{}) }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Token usage") + want := []string{"Plan · Input 1200", "Plan · Output 300", "Search · Input 4800"} + for i, w := range want { + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != w { + t.Fatalf("mark %d readout %q, want %q", i, got, w) + } + } + if !tt.HasText("Input") || !tt.HasText("Output") { + t.Fatal("legend missing") + } +} + +func TestTokenUsageChartLineEmptyInvalid(t *testing.T) { + usage := tokenUsageSample() + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return TokenUsageChart(c, usage, TokenUsageChartOptions{Line: true, Frame: ChartFrame{Label: "Tokens per step"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Tokens per step") + tt.Key(0, ui.KeyRight) + if h.res.Point < 0 { + t.Fatal("line chart has no keyboard path") + } + usage = nil + tt.Frame() + if !tt.HasText("No data") { + t.Fatalf("empty: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative tokens did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + TokenUsageChart(c, []TokenUsage{{Label: "x", Input: -1}}, TokenUsageChartOptions{}) + }, 300, 200, 1) +} + +func TestTokenUsageChartSnapshot(t *testing.T) { + usage := tokenUsageSample() + snapshot(t, "token_usage_chart", 520, 360, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + TokenUsageChart(c, usage, TokenUsageChartOptions{}) + }) + }) +} diff --git a/tool_registry_panel.go b/tool_registry_panel.go new file mode 100644 index 0000000..cafa661 --- /dev/null +++ b/tool_registry_panel.go @@ -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) + } + }) + } +} diff --git a/tool_registry_panel_test.go b/tool_registry_panel_test.go new file mode 100644 index 0000000..8e93561 --- /dev/null +++ b/tool_registry_panel_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func toolSample() []AgentTool { + return []AgentTool{ + {Name: "web_search", Description: "Search the web", Enabled: true, Params: []ToolParam{ + {Name: "query", Type: "string", Required: true, Description: "What to look for"}, + {Name: "limit", Type: "integer"}, + }}, + {Name: "read_file", Description: "Read a local file"}, + } +} + +func TestToolRegistryPanelToggleAndPreview(t *testing.T) { + var s ListState[string] + tools := toolSample() + disabled := false + var toggled []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(400).Children(func() { + r := ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{Disabled: disabled}) + r.Element.Grow(1) + if name, on, ok := r.Toggled(); ok { + toggled = append(toggled, name) + for i := range tools { + if tools[i].Name == name { + tools[i].Enabled = on + } + } + } + }) + }, 800, 420) + if !tt.HasText("Choose a tool to see its parameters") { + t.Fatalf("empty preview: %q", tt.Texts()) + } + if err := tt.Click("Search the web"); err != nil { + t.Fatal(err) + } + if !tt.HasText("query") || !tt.HasText("required") || !tt.HasText("integer") { + t.Fatalf("schema: %q", tt.Texts()) + } + if err := tt.Click("read_file"); err != nil { + t.Fatal(err) + } + if !tt.HasText("No parameters") { + t.Fatalf("no params: %q", tt.Texts()) + } + if err := tt.Click("Enable read_file"); err != nil { + t.Fatal(err) + } + if len(toggled) != 1 || toggled[0] != "read_file" || !tools[1].Enabled { + t.Fatalf("toggle %q enabled %v", toggled, tools[1].Enabled) + } + disabled = true + tt.Frame() + _ = tt.Click("Enable read_file") + if len(toggled) != 1 { + t.Fatal("disabled panel toggled") + } +} + +func TestToolRegistryPanelInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var s ListState[string] + ToolRegistryPanel(c, &s, []AgentTool{{Name: "a"}, {Name: "a"}}, ToolRegistryPanelOptions{}) + }, 300, 200, 1) +} + +func TestToolRegistryPanelSnapshot(t *testing.T) { + tools := toolSample() + snapshotAfter(t, "tool_registry_panel", 760, 300, func(c *ui.Context) { + s := ui.Local(c.Root(), "s", func() *ListState[string] { return &ListState[string]{} }) + ui.Column(c).Padding(16).Height(300).Children(func() { + ToolRegistryPanel(c, *s, tools, ToolRegistryPanelOptions{}).Element.Grow(1) + }) + }, func(tt *ui.Tester) { _ = tt.Click("Search the web") }) +} diff --git a/trace_viewer.go b/trace_viewer.go new file mode 100644 index 0000000..7304c72 --- /dev/null +++ b/trace_viewer.go @@ -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()) +} diff --git a/trace_viewer_test.go b/trace_viewer_test.go new file mode 100644 index 0000000..f6b3de1 --- /dev/null +++ b/trace_viewer_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func traceSample() []TraceSpan { + ms := time.Millisecond + return []TraceSpan{ + {ID: "root", Name: "agent.run", Start: 0, End: 900 * ms, Attrs: []TraceAttr{{"model", "opus"}}}, + {ID: "plan", Parent: "root", Name: "llm.plan", Start: 10 * ms, End: 210 * ms, Attrs: []TraceAttr{{"tokens", "1200"}}}, + {ID: "search", Parent: "root", Name: "tool.search", Start: 220 * ms, End: 520 * ms, Failed: true}, + {ID: "fetch", Parent: "search", Name: "http.get", Start: 230 * ms, End: 500 * ms}, + {ID: "write", Parent: "root", Name: "llm.write", Start: 530 * ms, End: 880 * ms}, + } +} + +func TestTraceBuild(t *testing.T) { + ix := traceBuild(traceSample()) + if len(ix.roots) != 1 || ix.roots[0] != "root" || len(ix.kids["root"]) != 3 || ix.end != 900*time.Millisecond { + t.Fatalf("index %+v", ix) + } + for name, spans := range map[string][]TraceSpan{ + "dup": {{ID: "a"}, {ID: "a"}}, + "time": {{ID: "a", Start: 2, End: 1}}, + "cycle": {{ID: "a", Parent: "b"}, {ID: "b", Parent: "a"}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + traceBuild(spans) + }() + } +} + +func TestTraceViewerSelectShowsAttributes(t *testing.T) { + var s TraceViewerState + spans := traceSample() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Height(600).Children(func() { + TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Grow(1) + }) + }, 900, 620) + if !tt.HasText("llm.plan") || !tt.HasText("200.0 ms") { + t.Fatalf("tree: %q", tt.Texts()) + } + if _, ok := tt.Find("tool.search, failed"); !ok { + t.Fatal("failed span not named") + } + if tt.HasText("http.get") { + t.Fatal("nested span shown before opening its parent") + } + if err := tt.Click("llm.plan"); err != nil { + t.Fatal(err) + } + if id, _ := s.Tree.Selected(); id != "plan" || !tt.HasText("tokens") || !tt.HasText("1200") { + t.Fatalf("attributes: %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if id, _ := s.Tree.Selected(); id != "search" || !tt.HasText("http.get") { + t.Fatalf("keyboard open: %q", tt.Texts()) + } +} + +func TestTraceViewerSnapshot(t *testing.T) { + spans := traceSample() + snapshot(t, "trace_viewer", 820, 420, func(c *ui.Context) { + s := ui.Local(c.Root(), "s", func() *TraceViewerState { + st := &TraceViewerState{} + st.Tree.SetOpen("search", true) + return st + }) + ui.Column(c).Padding(16).Height(420).Children(func() { + TraceViewer(c, *s, spans, TraceViewerOptions{}).Element.Grow(1) + }) + }) +} diff --git a/transitions_test.go b/transitions_test.go new file mode 100644 index 0000000..e42cfa3 --- /dev/null +++ b/transitions_test.go @@ -0,0 +1,210 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestPresenceEntersExitsAndRemoves(t *testing.T) { + for _, kind := range []PresenceKind{PresenceFade, PresenceSlide, PresenceCollapse} { + show := false + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { + Presence(c, show, PresenceOptions{Kind: kind}, func() { ui.Text(c, "Notice") }) + ui.Text(c, "Below") + }) + }, 300, 120) + if tt.HasText("Notice") { + t.Fatal("hidden content built") + } + show = true + before := *n + tt.Frame() + if !tt.HasText("Notice") || *n-before < 3 { + t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before) + } + time.Sleep(250 * time.Millisecond) + tt.Frame() + show = false + before = *n + tt.Frame() + if *n-before < 3 { + t.Fatalf("kind %d: no exit motion", kind) + } + time.Sleep(250 * time.Millisecond) + tt.Frame() + if tt.HasText("Notice") || motionBusy(tt, n) { + t.Fatalf("kind %d: not removed after exit", kind) + } + } +} + +func TestPresenceReducedMotionAndInvalid(t *testing.T) { + show := false + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { Presence(c, show, PresenceOptions{Kind: PresenceSlide}, func() { ui.Text(c, "Notice") }) }) + }, 300, 120) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + show = true + if motionBusy(tt, n) || !tt.HasText("Notice") { + t.Fatal("reduced motion animated") + } + defer func() { + if recover() == nil { + t.Fatal("unknown kind did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1) +} + +func TestStaggerRevealsInTurnOnce(t *testing.T) { + count := 5 + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { + Stagger(c, count, StaggerOptions{Interval: 50 * time.Millisecond}, func(i int) { ui.Textf(c, "Row %d", i) }) + }) + }, 300, 240) + if !tt.HasText("Row 0") || tt.HasText("Row 4") { + t.Fatalf("first frame: %q", tt.Texts()) + } + time.Sleep(120 * time.Millisecond) + tt.Frame() + if !tt.HasText("Row 2") || tt.HasText("Row 4") { + t.Fatalf("after 120 ms: %q", tt.Texts()) + } + time.Sleep(300 * time.Millisecond) + tt.Frame() + time.Sleep(250 * time.Millisecond) + tt.Frame() + if !tt.HasText("Row 4") || motionBusy(tt, n) { + t.Fatalf("did not finish and settle: %q", tt.Texts()) + } + count = 6 + tt.Frame() + if !tt.HasText("Row 5") { + t.Fatal("item added after the sequence waited") + } +} + +func TestStaggerReducedMotionAndInvalid(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if c.Preferences().ReduceMotion { + Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Row %d", i) }) + } + }, 300, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("Row 2") { + t.Fatalf("reduced motion staggered: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative n did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1) +} + +func TestReorderTransitionSlides(t *testing.T) { + keys := []string{"a", "b", "c"} + tt, n := motionFrames(func(c *ui.Context) { + ReorderTransition(c, keys, ReorderTransitionOptions{Gap: 4}, func(k string) { + ui.Text(c, "Item "+k).Padding(8) + }) + }, 300, 200) + ra, _ := tt.Find("Item a") + keys = []string{"c", "b", "a"} + before := *n + tt.Frame() + if *n-before < 3 { + t.Fatal("reorder did not animate") + } + mid, _ := tt.Find("Item a") + time.Sleep(260 * time.Millisecond) + tt.Frame() + end, _ := tt.Find("Item a") + if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) { + t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y) + } + defer func() { + if recover() == nil { + t.Fatal("duplicate key did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {}) + }, 100, 50, 1) +} + +func TestLayoutTransitionResizes(t *testing.T) { + wide := false + var w float32 + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).AlignItems(ui.Start).Children(func() { + box := LayoutTransition(c, LayoutTransitionOptions{Clip: true}, func() { + width := float32(100) + if wide { + width = 300 + } + ui.Box(c).Size(width, 40) + }) + w = box.Bounds().W + }) + }, 400, 120) + wide = true + before := *n + tt.Frame() + if *n-before < 3 { + t.Fatal("resize did not animate") + } + time.Sleep(260 * time.Millisecond) + tt.Frame() + if motionBusy(tt, n) || w != 300 { + t.Fatalf("settled width %v", w) + } + defer func() { + if recover() == nil { + t.Fatal("negative duration did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, nil) }, 100, 50, 1) +} + +func TestPresenceSnapshot(t *testing.T) { + snapshot(t, "presence", 300, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Presence(c, true, PresenceOptions{}, func() { ui.Text(c, "The doors open.") }) + }) + }) +} + +func TestStaggerSnapshot(t *testing.T) { + snapshot(t, "stagger", 300, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Guest %d arrives", i+1) }) + }) + }) +} + +func TestReorderTransitionSnapshot(t *testing.T) { + snapshot(t, "reorder_transition", 300, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ReorderTransition(c, []string{"Sakiko", "Uika", "Mutsumi"}, ReorderTransitionOptions{Gap: 4}, func(k string) { + ui.Text(c, k).Padding(4, 8).Background(Tokens(c).Surface).Radius(4) + }) + }) + }) +} + +func TestLayoutTransitionSnapshot(t *testing.T) { + snapshot(t, "layout_transition", 300, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() { + LayoutTransition(c, LayoutTransitionOptions{}, func() { + ui.Text(c, "A panel that grows").Padding(12).Background(Tokens(c).Surface).Radius(4) + }) + }) + }) +} diff --git a/typewriter.go b/typewriter.go new file mode 100644 index 0000000..b1bc810 --- /dev/null +++ b/typewriter.go @@ -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 +} diff --git a/typewriter_test.go b/typewriter_test.go new file mode 100644 index 0000000..23f2103 --- /dev/null +++ b/typewriter_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestTypewriterRevealsFinishesAndSkips(t *testing.T) { + text := "The court assembles at dusk." + finished := 0 + tt, n := motionFrames(func(c *ui.Context) { + if Typewriter(c, text, TypewriterOptions{Speed: 100, Skippable: true}).Finished() { + finished++ + } + }, 400, 120) + if tt.HasText(text) && motionCount(tt, text) == 2 { + t.Fatal("whole text shown at once") + } + time.Sleep(120 * time.Millisecond) + tt.Frame() + shown := 0 + for _, s := range tt.Texts() { + if s != text && s != "Skip" && len(s) > shown { + shown = len(s) + } + } + if shown < 5 || shown >= len(text) { + t.Fatalf("partial reveal %d of %d: %q", shown, len(text), tt.Texts()) + } + if err := tt.Click("Skip"); err != nil { + t.Fatal(err) + } + if motionCount(tt, text) != 2 || finished != 1 || tt.HasText("Skip") || motionBusy(tt, n) { + t.Fatalf("skip: finished %d texts %q", finished, tt.Texts()) + } + text += " The music begins." + tt.Frame() + if motionCount(tt, "The court assembles at dusk.") != 1 { + t.Fatalf("appended text restarted: %q", tt.Texts()) + } + time.Sleep(300 * time.Millisecond) + tt.Frame() + if motionCount(tt, text) != 2 || finished != 2 { + t.Fatalf("append: finished %d %q", finished, tt.Texts()) + } +} + +func TestTypewriterReducedMotionAndInvalid(t *testing.T) { + finished := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + finished = Typewriter(c, "All at once", TypewriterOptions{}).Finished() || finished + }, 300, 80) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + _ = tt + tt2 := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + finished = Typewriter(c, "Shown now", TypewriterOptions{}).Finished() || finished + }, 300, 80) + tt2.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionCount(tt2, "Shown now") != 2 || !finished { + t.Fatalf("reduced motion: %q", tt2.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative speed did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); Typewriter(c, "x", TypewriterOptions{Speed: -1}) }, 100, 50, 1) +} + +func TestTypewriterSnapshot(t *testing.T) { + snapshot(t, "typewriter", 420, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Typewriter(c, "The court assembles at dusk; the music begins.", TypewriterOptions{Skippable: true}) + }) + }) +} diff --git a/wave_loader.go b/wave_loader.go new file mode 100644 index 0000000..f82b161 --- /dev/null +++ b/wave_loader.go @@ -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) + }) +}