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 0000000..96707b3 Binary files /dev/null and b/testdata/snapshots/darwin/bars_loader-dark.png differ diff --git a/testdata/snapshots/darwin/bars_loader-light.png b/testdata/snapshots/darwin/bars_loader-light.png new file mode 100644 index 0000000..dbe7c19 Binary files /dev/null and b/testdata/snapshots/darwin/bars_loader-light.png differ diff --git a/testdata/snapshots/darwin/dots_loader-dark.png b/testdata/snapshots/darwin/dots_loader-dark.png new file mode 100644 index 0000000..bb32f30 Binary files /dev/null and b/testdata/snapshots/darwin/dots_loader-dark.png differ diff --git a/testdata/snapshots/darwin/dots_loader-light.png b/testdata/snapshots/darwin/dots_loader-light.png new file mode 100644 index 0000000..c2c3c13 Binary files /dev/null and b/testdata/snapshots/darwin/dots_loader-light.png differ diff --git a/testdata/snapshots/darwin/orbit_loader-dark.png b/testdata/snapshots/darwin/orbit_loader-dark.png new file mode 100644 index 0000000..f80e876 Binary files /dev/null and b/testdata/snapshots/darwin/orbit_loader-dark.png differ diff --git a/testdata/snapshots/darwin/orbit_loader-light.png b/testdata/snapshots/darwin/orbit_loader-light.png new file mode 100644 index 0000000..fdf0dce Binary files /dev/null and b/testdata/snapshots/darwin/orbit_loader-light.png differ diff --git a/testdata/snapshots/darwin/pulse_loader-dark.png b/testdata/snapshots/darwin/pulse_loader-dark.png new file mode 100644 index 0000000..715cc24 Binary files /dev/null and b/testdata/snapshots/darwin/pulse_loader-dark.png differ diff --git a/testdata/snapshots/darwin/pulse_loader-light.png b/testdata/snapshots/darwin/pulse_loader-light.png new file mode 100644 index 0000000..c971922 Binary files /dev/null and b/testdata/snapshots/darwin/pulse_loader-light.png differ diff --git a/testdata/snapshots/darwin/wave_loader-dark.png b/testdata/snapshots/darwin/wave_loader-dark.png new file mode 100644 index 0000000..6010e38 Binary files /dev/null and b/testdata/snapshots/darwin/wave_loader-dark.png differ diff --git a/testdata/snapshots/darwin/wave_loader-light.png b/testdata/snapshots/darwin/wave_loader-light.png new file mode 100644 index 0000000..143110b Binary files /dev/null and b/testdata/snapshots/darwin/wave_loader-light.png differ diff --git a/token_usage_chart.go b/token_usage_chart.go new file mode 100644 index 0000000..11e139e --- /dev/null +++ b/token_usage_chart.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "strconv" + + "github.com/egoist/mygo/ui" +) + +// TokenUsage is the tokens one session or step used. +type TokenUsage struct { + Label string + Input, Output int +} + +// TokenUsageChartOptions configure a TokenUsageChart. +type TokenUsageChartOptions struct { + // Frame names, sizes and states the chart; an empty Label uses the built-in name. + Frame ChartFrame + // Line draws input and output as lines instead of stacked bars. + Line bool +} + +type tokenUsageSeries struct { + src *TokenUsage + n int + labels []string + series []ChartSeries +} + +// TokenUsageChart shows input and output tokens per session or step, as stacked bars or lines. +func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) ChartResult { + if opts.Frame.Label == "" { + opts.Frame.Label = msg(c, "token_usage_chart.label") + } + holder := ui.Column(c).AlignItems(ui.Stretch) + cache := ui.Local(holder, "series", func() *tokenUsageSeries { return &tokenUsageSeries{} }) + s := *cache + var first *TokenUsage + if len(usage) > 0 { + first = &usage[0] + } + if s.series == nil || s.src != first || s.n != len(usage) { + in, out := make([]float64, len(usage)), make([]float64, len(usage)) + s.labels = make([]string, len(usage)) + for i, u := range usage { + if u.Input < 0 || u.Output < 0 { + panic("mujica: TokenUsage counts must not be negative") + } + in[i], out[i], s.labels[i] = float64(u.Input), float64(u.Output), u.Label + } + s.series = []ChartSeries{{Name: msg(c, "token_usage_chart.input"), Values: in}, {Name: msg(c, "token_usage_chart.output"), Values: out}} + s.src, s.n = first, len(usage) + } + format := func(v float64) string { return strconv.FormatFloat(v, 'f', 0, 64) } + var res ChartResult + holder.Children(func() { + if opts.Line { + res = LineChart(c, s.series, LineChartOptions{Frame: opts.Frame, Format: format}) + return + } + res = BarChart(c, s.labels, s.series, BarChartOptions{Frame: opts.Frame, Stacked: true, Format: format}) + }) + res.Element = holder + return res +} diff --git a/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) + }) +}