WIP motion 1

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:49:07 -04:00
1 parent fbd584b6aa
commit ec26a33995
32 files changed
+1620

No files matched your search

+23
View File
@@ -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)
}
})
}
+81
View File
@@ -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
}
+152
View File
@@ -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: 110, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.input, b.input) }, Text: func(r costRow) string { return count(r.input) }},
{ID: "output", Title: msg(c, "cost_breakdown.output"), Width: 110, Align: ui.End, Sortable: true, Compare: func(a, b costRow) int { return cmp.Compare(a.output, b.output) }, Text: func(r costRow) string { return count(r.output) }},
{ID: "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
}
+65
View File
@@ -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() })
}
+80
View File
@@ -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))
}
})
}
+115
View File
@@ -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
}
+61
View File
@@ -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
}
+41
View File
@@ -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
}
+115
View File
@@ -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) }
+55
View File
@@ -0,0 +1,55 @@
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.required": {"required", "必填"},
"typewriter.skip": {"Skip", "跳过"},
"blink_highlight.up": {"up", "上升"},
"blink_highlight.down": {"down", "下降"},
})
}
+26
View File
@@ -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)
})
}
+71
View File
@@ -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
}
+21
View File
@@ -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))
})
}
+43
View File
@@ -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
}
+68
View File
@@ -0,0 +1,68 @@
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.Box(c).Key(still).Height(h).Role(ui.RoleProgress).Label(label)
if opts.Width > 0 {
e.Width(opts.Width)
} else {
e.FillWidth()
}
e.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, fill, theme.CardRadius)
if still {
return
}
p.AnimationFrame()
t := float32(p.Now().UnixMilli()%1600) / 1600
band := max(r.W*0.25, 24)
x := internal.Snap(p, r.X-band+(r.W+band)*t)
p.Clip(r, theme.CardRadius, func() {
p.Fill(ui.Rect{X: x, Y: r.Y, W: band, H: r.H}, k.Surface.Alpha(0.55), 0)
})
})
return e
}
+43
View File
@@ -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
}
+71
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+65
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"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{Description: t.Name}).Label(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)
}
})
}
}
+184
View File
@@ -0,0 +1,184 @@
package mujica
import (
"cmp"
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"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)
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)))
p.Fill(ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: internal.Snap(p, w), H: r.H}, 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())
}
+86
View File
@@ -0,0 +1,86 @@
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)
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 = false
}
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])).Label(text)
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
}
+30
View File
@@ -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)
})
}