From ec26a339958e01981367dc79fb9574d83527ab5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:49:07 -0400 Subject: [PATCH] WIP motion 1 --- bars_loader.go | 23 +++ blink_highlight.go | 81 ++++++++ cost_breakdown.go | 152 +++++++++++++++ count_up.go | 65 +++++++ dots_loader.go | 80 ++++++++ eval_result_table.go | 115 +++++++++++ interrupt_button.go | 61 ++++++ layout_transition.go | 41 ++++ loaders_test.go | 115 +++++++++++ messages_motion.go | 55 ++++++ orbit_loader.go | 26 +++ presence.go | 71 +++++++ pulse_loader.go | 21 ++ reorder_transition.go | 43 ++++ shimmer.go | 68 +++++++ shimmer_text.go | 43 ++++ stagger.go | 71 +++++++ .../snapshots/darwin/bars_loader-dark.png | Bin 0 -> 3286 bytes .../snapshots/darwin/bars_loader-light.png | Bin 0 -> 3167 bytes .../snapshots/darwin/dots_loader-dark.png | Bin 0 -> 4246 bytes .../snapshots/darwin/dots_loader-light.png | Bin 0 -> 4166 bytes .../snapshots/darwin/orbit_loader-dark.png | Bin 0 -> 4008 bytes .../snapshots/darwin/orbit_loader-light.png | Bin 0 -> 3788 bytes .../snapshots/darwin/pulse_loader-dark.png | Bin 0 -> 3809 bytes .../snapshots/darwin/pulse_loader-light.png | Bin 0 -> 3740 bytes .../snapshots/darwin/wave_loader-dark.png | Bin 0 -> 5486 bytes .../snapshots/darwin/wave_loader-light.png | Bin 0 -> 5371 bytes token_usage_chart.go | 65 +++++++ tool_registry_panel.go | 124 ++++++++++++ trace_viewer.go | 184 ++++++++++++++++++ typewriter.go | 86 ++++++++ wave_loader.go | 30 +++ 32 files changed, 1620 insertions(+) create mode 100644 bars_loader.go create mode 100644 blink_highlight.go create mode 100644 cost_breakdown.go create mode 100644 count_up.go create mode 100644 dots_loader.go create mode 100644 eval_result_table.go create mode 100644 interrupt_button.go create mode 100644 layout_transition.go create mode 100644 loaders_test.go create mode 100644 messages_motion.go create mode 100644 orbit_loader.go create mode 100644 presence.go create mode 100644 pulse_loader.go create mode 100644 reorder_transition.go create mode 100644 shimmer.go create mode 100644 shimmer_text.go create mode 100644 stagger.go create mode 100644 testdata/snapshots/darwin/bars_loader-dark.png create mode 100644 testdata/snapshots/darwin/bars_loader-light.png create mode 100644 testdata/snapshots/darwin/dots_loader-dark.png create mode 100644 testdata/snapshots/darwin/dots_loader-light.png create mode 100644 testdata/snapshots/darwin/orbit_loader-dark.png create mode 100644 testdata/snapshots/darwin/orbit_loader-light.png create mode 100644 testdata/snapshots/darwin/pulse_loader-dark.png create mode 100644 testdata/snapshots/darwin/pulse_loader-light.png create mode 100644 testdata/snapshots/darwin/wave_loader-dark.png create mode 100644 testdata/snapshots/darwin/wave_loader-light.png create mode 100644 token_usage_chart.go create mode 100644 tool_registry_panel.go create mode 100644 trace_viewer.go create mode 100644 typewriter.go create mode 100644 wave_loader.go 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/cost_breakdown.go b/cost_breakdown.go new file mode 100644 index 0000000..b86296c --- /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: 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 +} 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/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/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/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..cc4cc5a --- /dev/null +++ b/messages_motion.go @@ -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", "下降"}, + }) +} 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..b4df863 --- /dev/null +++ b/shimmer.go @@ -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 +} 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 0000000000000000000000000000000000000000..96707b3136bbc61215f8a3e535db997e5635b1ad GIT binary patch literal 3286 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

MKO8?`D3UFbU86`aSAdmrl2&ArZ zS&Y8=U1|N)2QvWF01*0>zSXixa3RyR`a`f}&vou{hs+=nCZRoxp|ot?qS|#DrINQ| z|DX=Wy|u*(f%Kam^g>1K?J?Qddor@GtKCv7V+{kY(daX%>}{Vlmxe3O(UWFca6!a$ zrcu-R2HS#N^7{aNgb1pk1LIRdCH8Chllw-;X2G#l>|{8y!t;bb6qNz;+vw1;q*5oO zWi?2euI>tJ&>gW)o*W`l4oC`Jd1XX^!7;<6V!*2aHlM?tPe4%Wt#ITZlc}p$c-eyo z6SHU%jeJ1qX$ipz+m7!1IqBU?RJx)mZ8TmSYT+d~u<62%RRw(WfKFYbiHaXF#NEB{ z!SQPJbn44F_EB?v<6p4>jrJ5Sy!Qz*b6A|sU7HLw_!GdNBw@*Y{h%eYM5Uu(~8O$uJ&QabFhI3{sAANh-1(Qt+8zz zJB&`i2{J@QA=FF^MQH(e+aZqODm-N%vUTbVsaO7^VEsu5RNA?=#-$tf1{5&f&8M1D z;k*P*8nFIlT30kYKqufQMqn~zw^V9mFp(>$=qVW#Ow0P`WE*0?Y`n3HnfC>J_q=$@BL$z+eX!7eRWK{!Z^ zX+8w0dlwruk7a?85??NaPEo2ApXTRp3#)0XQ5kS+f$EvN=5CPTE>i;niSFkgC6a7*!r)E&_3 z791U*d^MU|wRTn_n=IntVO1x0NZ3?J;LDd?^OVMhJk6;wm1IOVSu)V9Hl?>=tE4c zV&pKl6MuAu1RNx#{?ruK#j}9ymrJgQO%*iV&kHT)&}kd8qbsK zhu)u4-tSuqYZO)&!$=!RZ}UqT&=s$mnYBh9%NmY8@4VtnL5Um$LugeApv3l7LHn7fbc|MTJ!8@^PD zA7SS9zb5IU3`)PK%_?jS-DhB!wMySCpn%6R0Pf2^a89$QJ28G@4>3w|$+Ivr6ms5H z(k&k{d1CKQo8E3y1Bp-USw2D(n%>!TQE#ZLu83t@T`CoLA|WB1C*rK14fc)?yDQr> z{C)hcp_N6d7`mR>+sE$YW712}f?)?t#mzrgag%!lm4EuFIFZ6|+{NK!w?_1UdZ z>_YB@BagtCQ`2K~!LM9uvW%e{1kKWQy5AvhLKN{)T6tnt1F2QKO$oJte1%-=6b+NJ zs8jHduyLX$Cd!fa9|w{YnfFeG)g`TfZ-o9S0Qe+$(b_o=B#j=F8rHFR4!*zOYWW!q z{Oh`gzUekR)%8(k{kBb0LtP_93=rfc5qMA}blWG^aGw{hiddd0^X7i1;Z}6tiBO+> zn7FmracRQ^AcY9s)q%V#GA>zhJG#H}+n#TH#W{V5y!BO#chdyj}0)mm&$2dh_($YEKC;Byh zN(pb0(@LMQNKE~xTbSRtKZg44EcH+*bYpBIf@i)NGrII{qH|3)+_i|6w_rXtMj55s z=(%)wF(CP{pS2IyS)iKpOdL0mY<^L?7}R$59>0OmI%h&u1zyW1=it>H5)*M_tx6!qVQ2F7vN_7DQS0jP8&uE(D9@&fRu?_1;Vb zk*js60pD2=$X8@oiFw`|PMHFI|AJP5zQ8oycsqxlUlM<^by|3O-h7n}=R8ftr*wZ48z8x`LdbtC-P(;qrrfmfK_K9`&BQGY&0U>5{o zK0f->AksG&%Xb2@bE}Er4tkqUW51b!pTH&g~b6r4> zpqpxmw-e_wuDBu^o>@I_-KgzmOsIbXZN*`mp zZU@Z`!Q{#mx-3>kVj-M$={xh%76zERn^_kB*{ZhGWD zrKi8%^V`o{G7h#mn4c$JW{mzO{^XMvH4K4hBvwV#&}el@lXynE%%)=9Rc0*lVqWZ8 zB7H;K^N4*uqf7L0VgTW{d0v-7xhf{L;V?j3?mphu`@z^7sW!36h{*1|wrd2^W|& zyF$ppVW}ps$AY*tB0_9&S!~hn@CCM3#=l$}OGiV@J9w0=m(xt_a0Ulz)1{-DYu)i7 zWv|IOm8ODmy_|p<)<9SN$&m_G5~jNNj(%FNyh0G3z7>#8_Rc-GjfAZOS9c460Ze6BAS#>XBn5=;5k?o;e?EE9I}&H z6Zq-AL_+M1Sj&fO-#A$sT1>ZtDM4+s13`w=v-;Qf_pe*je(04Gq|>;Q04Kd8NSLmC z82cD$+kSC^By2f5i${$($eqWgH*=terV=!8k8obkamXyNh(jn4*R0mKJt!-Dy!XU){FP*8Z@v}4a?{J);dir6f2W;*CI<>em= zT!r)KOp5><9$w)b`amm}MS0`D?Ee!EDpZY>6zbot!_b4+xl61mAX-l~%QdV+{sRl8 B-uwUn literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/dots_loader-light.png b/testdata/snapshots/darwin/dots_loader-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c2c3c1351f7048837db60c45f59cae722307ce18 GIT binary patch literal 4166 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/orbit_loader-dark.png b/testdata/snapshots/darwin/orbit_loader-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f80e8765a812d69764af9e4224f31536dde8dc27 GIT binary patch literal 4008 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 P0tXoCn&?zRonrn0Eb$y6 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/orbit_loader-light.png b/testdata/snapshots/darwin/orbit_loader-light.png new file mode 100644 index 0000000000000000000000000000000000000000..fdf0dcea11d882ce29e0b60fee1026ab0aeb60ed GIT binary patch literal 3788 zcmdT{X*3jU8y*=wdtc9GPHw{=Ne0?Ue9^SRqK9e5%bpRh z0*Uo}#J5!>(&}1}S{^Cyr>0vL5t8Q^td_DoJf2c+@Ad5c2NyLPIZbW3L>{G=Mqnec z1)b-TuY;ckH<96b=VUnw5`0=F&-&!(h~7}7R;uwoQHJR; zm=3<%-~x$p4}Eucf-3U!9g7xwXfJY=Ky+VjaJ8!KMUOHdSwe)S1J#!T`wKTDEJ9vJ zw~=A11}wcKb)+1_f0M?@1l0jEyB0SOay`lAUH?07KylEo&He9Lp@}m*@wT3dp6tVz z!YT1T4v}-#bVoEJC+Nx4?-OHUJe+)A@PLR9I_j3SoHfcc$9$pK!|cXYb%haF3DkqT zQhIHm1Ii;_@*sPMs^f9rw>E0eHhyr zeYAA|;%Coj+b&3JbNDnQj^cCP-}*S8Z-DdHDQq{$YPB&VvRuIu2~Kl03pH>Q_!=VD zeqR=neoH8#h+HxDXGZ?qvWPe`Q$P#_SpuX1^eUxF-6=9d0 zL#9gogX_M$EZH{1l@V8aSJgmb7D+~BmKGixg^jVoXkn--W7{meTwHaqx&qg=xHa3N zu|1r?IdbURPyoA(?jqk!V4y60jf{0YvY2Dr3Aoo7*&Qq`(G58a=nBFNf7>Mny%A??4ePhMH&fE8Y#5=PK5QD@G5w&;Aw8 ztdSwibLFLzlJ528IEr%V0p`Psmo3~4-ufaju_Cei5d<=Tqq_PN!Q#kH@$ z!)r)*64G(qFPw6Z+F>SrEz~(dTSgm%e*4A>`7xkoa+|kDD|lftXT4Un(#2cLjEi+Z zh=0S+XiS9^lC)NSmi?-PH>p+)(|S&F!Xei?z3tWBzUh*ED`RuYOw( z=6um@7poJBt%e)*9v(}n$_tGPJYHoFbfWG6ifDG!S=S`%GJDLoPx}~3W~8U?3DhH- zmGgpvW4$H!watTwg&>Fym?M6UMqExffBvTT)I%uWHOE>Mm}LH}cs@S;g$b`ui0iEp zvYda-*y1)5?NS67_}=UBJ!K+ub2?GmVdAOUqVw!W4NURc4 z4FQS;V75BFsjKvtGMl=hBZttsPOUTrEveDvool|42V;u`$JIxRYYBc>LUHqH6jc0j z3>P%H(7+2=_U+S;u`Gz+pdRXVm2K%?=IW5ScV$si<2S6fl=zaj{K-N;Ibx@#{lcxt zADjHtog z;u7fn#Zv2)W3Ow0Yc_YiO})e5B(JyU#44Zmbk-HDQp4(O$872ABsB^PiFBw%Vl$;o ze5by>Bj66>hM|wo1+$wOCII?f`1)sRe>_1-Qt_P5d^{PjnR-i5Mc_D04c4mkE2v7v zE~0doM|X)rF2!iQ(>cK z1n>Rtdsr|x2-wWvo-dglH^)d&v6%#QSbE6t7`&d=H($^0V^h)MEKO*5aYx^lDG!l~ zZed=3rT%-;M@nlKtXndtRN15`uc`wsYHf6dJi47I@VwO-$|sGvO{wpB(1QH@m58}m z?5rQ3NN3@Bh>l%YcG-{{VXVldsN+#gR#p}Z!|{>{kcY)a@YqJM`NGV~6R8XjC3}*H zQx8x5^Yxny4XWAbJT#q;b~&qp`)JLvVxlmy{5{s)dlNNREPrGE3H@V(SluSUh> z_QgDyA05=*r%s2`r!b8CDSiF}j1Mh4d zL>LX0q|b8a^!$9_g_9{Y%ziWP7Y>R6C!#|5BI|b|M)hUrGg-==9A&flh|$0yryt4%BpBMpH@l zOUbtuAHwn)MZwzP15M!SvE-WEh~S_;O%J8Iyl~M*{riV*-iZ3@VIf7DT3j& zidJ<;(xE&=$J_L3U}x5agL+{r|BqK_Wk!1lu+$vJ8ackbu5tdF>FrS1WkcW1vzzv( zl|-6dAhyi^S6g7g`bjR#l`ok4Zgn`VOzmv=FE_|T$PVoIP)4mbHry{SH;v3^C)ifCP4c-=hEO)`?{Loo5Ke}u?+jY z&*CP?ZOt3`CVm<|si_-g+`BZ(Cn|y(yziJs-wi!d5kxGk+|KMmpX%4E*d5K1cyY1i zBWfjOQ+Rv1*1*;ZbXQYTQ)e$4ePTnK`K@)1tdSyLcz8hv?30<(SQ~7?1$6#8(Qz+X zgZxA#>$clV-|aLsh%SLSF80qSlBOZ?5(@Y7C^@H*u>*?OX4wm8{ZC)3Mfj`CXRC4) z2;{-U_C>IxWS>vwGy~*Nx`9@)nwr}79+_UXqx|O+#GvEx${i?W8eF=_|0t=)!+kkd zW`-qEb^2m|ss)`C086g{8dgnnn^|_Z&oN7s=elGy6?#=t>=gygvhtgpSOn(i(l2)K zl$~>+BOy&mS56~LAFLN{U(-eAPSBs%%{CAX^e^prPyiFlZZ5i&K$GX9OL&b1hT|VG zqM%~q=2sVY?I5FzNS&<-T4=iaFocaF79;9~=*udE09!;E0U9gM*C2+!8O0>u$*s1! zgXnhM{npqeAl+K}ndrpq1{9)=ibb#SRu)o$#HP4XmzO7J`p>yDLm9J0;@rwrzRv>a zt;tcY0w=cqPZ$5ohlzhCg@5ln^nZnriqJld0-QB&*e@=y-&JMG5-N_ Cu9f=$ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/pulse_loader-dark.png b/testdata/snapshots/darwin/pulse_loader-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..715cc24bbf692730b06c1c22c907ce94b6d7f6ad GIT binary patch literal 3809 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 SwZPHDBS1$3u3n+~H0(cu$n@m^ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/pulse_loader-light.png b/testdata/snapshots/darwin/pulse_loader-light.png new file mode 100644 index 0000000000000000000000000000000000000000..c971922ef90f5f3aa43ed58e2225e131bc97d6c5 GIT binary patch literal 3740 zcmds4=Q|r*8;>f2N)-*I_Gs<4b`hG|vsI%;s8K6Miz3vh(S`~YwJSDh2`LY;wN*vY z1fc^lV$Vj6Qsd2Yz2n>a4?JJ)>zwo9T=#FBbN%k5TNZ{KZ2W8h0D!~TNY4rYV3?+_ z9e}6kb@K(+eE}!dhph#l ze=|007qt$r9C?ewXu!0BL0&&Bh)ZPZ!)hge*pCu%85sqhvMX4m0r*B2ctWlEPsa>(uyS67i#Pm)B>e;pXe6xI(eQen01(7L6zj0$V6*Au(Fo!f8-@ zTfFfpcyvf~W-0n)zaG!%VOwbxS&H58%q=Q!H!U7}$hUpr&bJdmi2rS*&JDodN-Y%w z{K?BqiCVcu=S4rB`N@jRxmm6;i&DhWyDvED$}a0m5L8Y2;?}l9&E9bKTMHk`yfge@ zPRy=`?AF7b$Q-d5DgHToJ@wz+w45z6u2Wa3pAM%3>rv42x?0lM^X$^c4!k6rR6H&G zgJGSEV_}J&s6;^D%G@4)2fF%UFgb9T-I#0q=p$w>=-ys3K#@6AS5TRJ%zE|fn{9MP^3#=w(^X-@C8*Kx>*J9mt#;S7V~bqtv0Glz z6j`5avh~+{YIN&r(*UqndHYMgLX8J2L!)i8WHUdGH(%wqePR@2r z(p@=7*utwaSe&4{JX-IW!bJW2fdXeAajKfHek8@Eo7m|5%{p_V!?p<$Hzawqk_OR?KjkaTaEQ7e%^&9KFU4`X#ym^`G zJSb2**nqL9|KkJZ7N~zbALPeC_X+nIQXJH|aKPZC0{xjOvCXG;pa+!iK@w0X=(O&u z3JHCf>crD5=Xn8&#Wl#wtKiC;6Z<@BoCy)0NQ8%>dla>y|7(K$2MtBy)!C`$N=pog zgPnteU3+I;TG&OHauef#OZ{Do%&f8#OCbC1B*ws|?PwYozf^0~>p_RcP9~G5zC+Ng z30+aD-wcO8frCSPWrCJMu0xF~M{`-)nwndhnx^HACZr&TU%R-HBs>CoE9CPp^CZ&= z$5yl~3^$uPQYatpT>%NJAI|g$XMr=v#>o8JPaUGyQ@$(N;_SQK>N%m!-Ky=vRr>)2dbNIvV%a^99Oy+QH~*yJ)f5Ajo5!`n<~P8>S!!wO*UmEx$})PjP7 zo$FB%4%atD(@e^r>L+yLP~t#n)hI?Ms-xviUe}Bnd*0X)FGaR`{;O0+Zf|>+U^eHC z$c}NNz*IREf!!ZC!mwfmJ3mu?hH5R+F})zyzt7I8<3Vn>XZx=|VHL!@Xw`W+W3&Tm zw~|{25rNov99Ahl__m{wg05X!SsC^nJbTpI(!$o92&aCnaj2J3yhKDBHRnX!7R!=Y zH*;w7qYX~vCo3>TJGL4RDF}wg4-R70u-9AgDt?(+FF)0!qAZb%nKU6DZN6(#lZ?Kn z&806{&5nKAKiJPRlR`XuC1H#*xMORjs^Y!UC^-I6jMlEXH$Whir`0Ld*?q))+uF@4 z`J=xDYWDHqEj#Awd)Wlf_~FrY95ZJ}Q*>#%*e(|_p6Y+?_1eU8@B%v{0bf%QKtEVj zt?noG*Usnnu$8$O1#&mGH&AhQSsg)YCmU2dahz?{t(>WlHt6Xv(gPO%bx_3iM_H<5 zfccp`p#)A}pMV+dxk2_q!fHEgVFj3xWKcT4I4|46Q=*!FBM@nsQfc-2nBh3m-nf=w?isecc*v5eouRo} z^j=isCJj6{Gc#15pUB^&iCqe5$!~R}{~_+|qhCi)JnIWSp2;ywS)=i5OryJ z?9;`Fz+GOd;AwUuuxU^_l*_I&!WX^cFKCI&9wHQ^n z{%1DO#SGz-i~vF=6Aemt)}*t~M&+T*=^6mM;jtCN7fTy^+~N21{nH5djf&}7+{-nq zcsKoSZNM+nWK8?%`r{`te?G|z+_5No+;I~8hVjvH-2PC132PiOTIGNo zRtb#5mqE@!12zI!9l3@_hHG6qgs!N(3+hzL0UmOsCZSyGavrHX@&jo~sa71iYEZf{ zY)Q`pWM1PRZBD7iK?hP{DL7G&eJAb08cT5@f~dROzF}}l`~GgeVF!dOMA3U;E!Q@6 zVR4hN8rEuJYLY2)|HD{<`?Ite*qT(FZltQ*MC9717v&@d=MU(r}B-gD~iwj@h?iyxD1cUJ^IAceaEslScRc0@) zver^jSIFZP#gs!CNNoxs;>eeU<=Hrb5F9wSaq{c={O}~C& zd#+5(*a~Os_=Ydiv~);aL*x7Jt37gh^gQ>Mjjj$nLy{)D?-i|n^A-?i(7%AsI9Bv| ziJ8&r$&i%J{G4&3F701oW2GepL|~DnVF^iLr8@)(r5hIsDN#bH zB_u>*>002~^F8mM@c#BZXYO;KIdkTRxv!af<~oDYd#XW8#ZCnP04?l^ssR8HEfREd z3Sz>&o5bJ%0L);Rs)~_+_Ez?5OQwFV?)Bpxp=j?zYt>>D4+Zglx1jAE0jh%DnAm)w zn1r%EQ&h*-TQchUjFROsWRk+{%rd?ulm?`v^d&Y0Co4M(PAwcp#YMgMv3rR*SkKqG zex98W2n4|wz$PD}%yTQ|=1m%L9+kWQt0l2IALd20H=Zvb0#$SW;;4~P&5rA$Pq#Q< zemN+O=2n!X`TRY%xTyG(+nNaw61M+i2v%n%5v%2F?8QtMs&NP^};#Ll& z6(l4bhO=`rSWjIY!@D%?ti@^-OVBL_mRljA3-X+=4o=FmYNJRK{*%jlRGD{pKyB09 zQ@9_4j)>gDF$sjc_0wQf!^ebN-r+@u(JS}KR}HntZ}pSYDY4CrQ#kLfsmYBhjp4o< zip&i_&2^8YH5b#vJxYbqRd4v>XLWVh$RHvvro0)QCJN*?R4na#BB;0pF?dLba(7iu z270)jk)D|xkdd1xRki8j_KO-??KV!zhia+lCK;luE1`x+&ENo+N7kd4TQq1kfvLbK z7KPM$=66M(xxTJCG8-Fr53fg}2hex5Og4oRNvu>$p?L89!p0WLwY^6IDV?9uO9u^i zt%>s6qn2L%xPJ4%Tm!aR-v8odvXnnrNC$kqrc7yL$wpoZl|RW*qE=xA#+i68hgKHc z9(59 zg2$)yYFwSGK9sVtZJ0bWHZd~B43`&*@-yyXDt9)oaMUKLjJ6*ITfePbSssv7g_2!e zP>Bq|w>cMWU(`*y5dCMN78xS=kXvPREz~sqTKhTryl2LCd@5Vd1gmGP6He5Ah*V!# zP<^83waFBoropzo%_%AS!4}kbF!zRLWeiOPsoOR4%S6{_PwxQDRJen)>hx!nt{C_f zvFA_=e!Z_GvV~soCIH+vanqmrz8xES%2DuIob+&R>ynAh<=#EYJwdX_SgQT|1}SW2 z@{-{o_TIWXc7c>PTPczT>JXRj_(}*dhV9HN`GRGRVd=5;!>atoh}3JoKMvCDn@g?X zj|iz5184E|$2xWhExf#U>T6`rmdg9MWV7-^o7o#S{Fs#=_KlQ^N@%`aFj?dnXqX+u zAyBE9kpe3YD|CX$dGe$VB>Lob4{xZ@GqrBV$w}k=nu{=(zY2QsSDhP&QLX3)>}o9|@E=QHG3 zzuF0SYVTix;sNJ2XJ_N~m6M}Ah%>8p%I97boGh|Np!^m^#MRiqY}SUeOV!c-z@P}| ztm!$(R<&?CH`R#J1?cXm&Cgd z&%>h}vM4Xde{Qa2_l9uJp>ivpmgpCDf;>XSA4)V>m;%#tZpZ~DjyOeqt(X7oxDco1 zg%51p)~=jAd2V}yGLVTZfu7^8H9IVNWUG6_qNB+h7_d*d z+%iKAR`{wTcR)1KqQjLghwWu**S&%c_UrLioC;C!Xv>VsCquZM?_C!GCkHCSR<0hd zae>*L-9K6@Z^R2aFAQos)j{CF==h9x(504VNyA9tjUay$9|VW?F%0e?UsSN*UC0F} zCuyhOmX_tMIic2mYOR9D{0aNwBw$GMmK6VTJy_ddv#766Kt$`{+l!fhjby-or5&N} z)Y~ig?irr?u{|hin+`?KoIwm^pO0U>e8e=2bE&cuSZJ-1v9>UHI@s;~%tO=72a`lz zT9rNL^g#3mF!x{uxwk<_mqjb>zf}91GPz(R;gxyRPosvz<`9dN49e8q$}{H=i6;66 z?W`HN;(ST*Jr(jeaN&GGz;_IqoPc@ffn`8Wn>%ZmEqZKzxZdE#<<^W&^nCl;4%XUO z4hJ@Ss=ZvB#R}N&)kN=qv+kD6BlwC6Zc%QZ~B1^{%R6b?Gy7x74B8V|DA(nZWwC4X{U%xL8k;tEh}V=l*Y!2`@9+-pRkpDPc{}?!p|Rkel$O)mV1cL zTRNrmCt2}czy8#e28(`ARQ{pz<%9Z_FRkuhh=~<6Khz*kKTk`Id}SDD1Cv7_9Q zAB8Le@wi2Dk!k@!na0N3ru8&k^Rl1Q7*lbS3UsM8LSp^fgFE+!hCX?#k5qcbWybFJ za9Wh`@qIejCm{}pm{whlQOwb4gJnz~jPY*ZzY>ypOsfQ;yK?h*{t%`?4TP1RpJun? zMW*iziR`&9w0o|ZYfJN{4oFeL)&%+xmv7(B>G_lNNUj^>S6H}eyXqxb9T)OD8qT(K zv;1<}$N{g}pwo%Og(GcqiGN2uq?Z-4qgq73*unkOUF*oDTs~}v!r-u`PJee_fQt*< zL;soZFwL-ujprac2OO%3}EChV_VC$=-Z1i0PzpYPS z#Tmh1Hf={U{w`^_gOrYm#SnDyq^7f$Yb5;oeHSdI3+Dd~ae4dh=Yhj$>7ePJUmSnl zb(jX63kAcmtr?p1p-T$|2C5!z^V9&rx-sjwcJkuQv|1@N+s@wa8$vOjHok235vNW9 zHQdCGA!W-EdGpkEdnnpA5sF9hk|Ch*JB}05l|j!eMCw0?EFQYSxbOD1MHDyyegl* z5EG$#CwueAW{((;JIKJ1qWq4181$@|%BonLPPw_Ypqd7YSpc5=GsU0153 z7Y|mOPWAg@zTf~fo9@e(=K@Fij$Rwg{A!Wl+|#eN=|?n#%sNR#sx2fm=6b;GAFz2E z#<9km7}Iw8%Y#?yD(J8G!YLvjl`Q9hzmZ?3-orp|*U2Xm0hg~G4dbY)OZa|gp~?Nv z{PPsdvHe-pMzK#qGHwEk=9&gC^oeLrA~35vEkRFV2AL=0;h{0`8-cAVysRtS#Q^Zf z!68B5pW4^n?S~uvn_IMN}yX=S58b? z;bLg>W_|jvechwrg;qrz3t+o+auI&hZR7UF zqw=bPf9Dw9g-FTGWf#y+VtHKI(TEcRN)-e`-%CB>T7TH$EylpD?CNqBd=x8nCyk@2 z$YTN37xN25PoMRdCD!|s>K%R0Lchg;;KzmrMoI6|!!Ax)>8_l_oh`qwuM`pnIL170 zix!f1`N_ZSpl~B6+G%I*w6UmyDVd-LxLRw$!j=}6yjq5IPmd^$!_$iSP^!XW>nF-X z!ot1{;I>xax*^8}tR8`_5xhm={pkl7+#n{_95ORA>(VjrB!$NFB#aUW$g+`e6nxV6 z2GT859cdCKE+k>)%Y=Mrs%K4Q_gJ8z+}6f_K(;Ir01%7^Pw6gfRfV+aVy&<(Iyw21 zicUq2q=r3ymNcEWkVp`co>Qr|aGcFEtE8XpuQ_Q{U~03ahyF_m31dI#RiPBf{{8|a znTd65aOH$DP@9zvj%s6Jcr8|}QOiAJo=I2ZcRa(HUusu-u+_m?ExR?U#OeNEergaQ z0`Q33Q`kCPP_YhdvD_JR?Tl zaC}%!FzgW97=L)0cXo?hsaNXMTa5p&hrrd=sVA1?Tgs0bDCAjs+Etrt;TOXEBieo# zZ(w24zvRWokAq#bPYsk;B&Xea-I&M7$=K!i^LYCrc=H?JsPhqIoK^sL%P+Xn!RK2tPlct7YxEyJ54Wv6RbGL0m+NFnFZdnTu zy;@4j_u3^1|Hj_>5VDeb{C4+EjczU5aHh#fnQQ1^mD%Ff_wT>z9Zo{|E2mBTtR`P< zZnjufGg%s%8xZd-7RjIH)#L{xo*qX=C!vaCblMspvf^NemN+}8 zRW9A3On+AGC;Kb*uaXCf3)lIvGh_OO`mz&QGMMM))+)TH7@H1@*%?wYQqJ|!z9ypl zjR49hxQ94a_}h?J=K7NYij!a4vtAFfk;{xZYrzdaFzy#8N}De}NFETqgxm!$jwb9B zZ1YbwO8yiCh1L$jJHsOEO=xc?#19CGhYco%yimAQwUx{ovF-?>e3{>Kf@DZBGENKX zIx+jZv}S(-rnz=C%3!%}K`Lg=S5!M~aU3>POioS}9m~+n4@+y7Jm-n=aee%9iI2%+X@aku z)K1-O;_@QW;PLIpVn}{Ls$??RUvK_7JN|Wcq{MXZp1zWO?{F5(Gpfxk)fkIb+fUx4QpqT=qqP-j_E*k5d zI0E(mQjKLQGnoE(7^H9RhfjpU_c6~=4cnR)kgLPlsb^zAR}(h(x~BSS%md$~4*+cv zd2PwZ`FTzHN<^Y%?=!PNWYN|HXmU?RZpXQlicEPr)|q0kE0D1J>JtHzemW%5Apfrz z2)$f*VrMWv0((m;UR9vuN?0INIiG@BuIVu7vx(`G@{S_AB$Kx{j~9`hVOPD9vfod< zUo$f?@!X=2QNZk;Rv`bC;Cj3UUbP%$K`ZOe=5}4iav7uwKNjq@wv0-~XNd8Jjwk+v zZ>NeaKc7f&ZGXqi+-xbHHF70=sOh#$?yK_y?WW+_P~$aS)vTcY%IC4We>ai&?}MdX zdQQ9wcM#u3qV;#HU7KWwVm>p`XElsarTS$LuXil)IM zW##-#b;KnXwG>r;IQF_nBaEveAF4n)_=naHtAIA|?&dg+%56z~FPqskMnPIALD&>P zR9;`lR-mF92T9!#j6Ok)Q{l~NU+~R>W|w#t=Kr_NkzqJ=reF_M4m!0hI(MPyeR2kc zyy77t?Dlwuu?lY+wQhe8xgnEJMC+_f!v25i|0f&*NqtEGE^c49U;Ywh1grs=+Edl) IN48=A12)qsqW}N^ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/wave_loader-light.png b/testdata/snapshots/darwin/wave_loader-light.png new file mode 100644 index 0000000000000000000000000000000000000000..143110b9f71f844b1a2a2f2b96d240651d7da71a GIT binary patch literal 5371 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 literal 0 HcmV?d00001 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/tool_registry_panel.go b/tool_registry_panel.go new file mode 100644 index 0000000..93867ac --- /dev/null +++ b/tool_registry_panel.go @@ -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) + } + }) + } +} diff --git a/trace_viewer.go b/trace_viewer.go new file mode 100644 index 0000000..b562cd1 --- /dev/null +++ b/trace_viewer.go @@ -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()) +} diff --git a/typewriter.go b/typewriter.go new file mode 100644 index 0000000..4d23dcb --- /dev/null +++ b/typewriter.go @@ -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 +} 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) + }) +}