WIP motion 1
No files matched your search
@@ -0,0 +1,23 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// BarsLoader shows four bars rising and falling in turn.
|
||||
func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1.25, 1000, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
w := r.W / 7
|
||||
for i := range 4 {
|
||||
h := float32(0.6)
|
||||
if !still {
|
||||
h = 0.3 + 0.7*loaderWave(t-float64(i)/8)
|
||||
}
|
||||
bh := internal.Snap(p, r.H*h)
|
||||
x := r.X + float32(i)*w*2
|
||||
p.Fill(ui.Rect{X: x, Y: internal.Snap(p, r.Y+(r.H-bh)/2), W: w, H: bh}, col, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// BlinkHighlightOptions configure a BlinkHighlight.
|
||||
type BlinkHighlightOptions struct {
|
||||
// Format writes the value; nil uses two decimals.
|
||||
Format func(v float64) string
|
||||
// Duration of the fade; zero means 800 ms.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type blinkState struct {
|
||||
value float64
|
||||
dir int
|
||||
at time.Time
|
||||
set bool
|
||||
}
|
||||
|
||||
// BlinkHighlight shows value and tints it once when it changes, up in Success, down in Danger.
|
||||
// An arrow marks the last direction, and stays as the only mark under reduced motion.
|
||||
func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *ui.Element {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || opts.Duration < 0 {
|
||||
panic("mujica: BlinkHighlight needs a finite value and a non-negative Duration")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = 800 * time.Millisecond
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
row := ui.Row(c).AlignItems(ui.Center).Gap(u).Padding(0, u*1.5).Radius(theme.ControlRadius).Shrink(0)
|
||||
st := ui.Local(row, "blink", func() blinkState { return blinkState{} })
|
||||
now := c.Now()
|
||||
if st.set && value != st.value {
|
||||
st.dir = 1
|
||||
if value < st.value {
|
||||
st.dir = -1
|
||||
}
|
||||
st.at = now
|
||||
slog.Debug("mujica: blink highlight", "from", st.value, "to", value)
|
||||
}
|
||||
st.value, st.set = value, true
|
||||
col := k.Success
|
||||
icon, word := "arrow-up", "blink_highlight.up"
|
||||
if st.dir < 0 {
|
||||
col, icon, word = k.Danger, "arrow-down", "blink_highlight.down"
|
||||
}
|
||||
name := format(value)
|
||||
if st.dir != 0 {
|
||||
name += ", " + msg(c, word)
|
||||
}
|
||||
row.Label(name)
|
||||
if dur := Motion(c, d); st.dir != 0 && dur > 0 {
|
||||
if p := float32(now.Sub(st.at)) / float32(dur); p < 1 {
|
||||
row.Background(col.Alpha(0.22 * (1 - p)))
|
||||
c.AnimationFrame()
|
||||
}
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Text(c, format(value)).FontFeatures("tnum").SingleLine()
|
||||
if st.dir != 0 {
|
||||
ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(col)
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// CountUpOptions configure a CountUp.
|
||||
type CountUpOptions struct {
|
||||
// Duration of a change; zero means 600 ms.
|
||||
Duration time.Duration
|
||||
// Decimals shown; Format overrides it.
|
||||
Decimals int
|
||||
// Format writes the shown value; nil uses Decimals.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
type countUpState struct {
|
||||
from, to float64
|
||||
start time.Time
|
||||
set bool
|
||||
}
|
||||
|
||||
// CountUp counts from the last value to value; reduced motion shows it at once.
|
||||
func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
panic("mujica: CountUp value must be finite")
|
||||
}
|
||||
if opts.Duration < 0 || opts.Decimals < 0 {
|
||||
panic("mujica: CountUp Duration and Decimals must not be negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = 600 * time.Millisecond
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return strconv.FormatFloat(v, 'f', opts.Decimals, 64) }
|
||||
}
|
||||
dur := Motion(c, d)
|
||||
now := c.Now()
|
||||
row := ui.Row(c).Shrink(0).Label(format(value))
|
||||
st := ui.Local(row, "count", func() countUpState { return countUpState{from: value, to: value} })
|
||||
at := func() float64 {
|
||||
if dur == 0 {
|
||||
return st.to
|
||||
}
|
||||
p := min(1, float64(now.Sub(st.start))/float64(dur))
|
||||
return st.from + (st.to-st.from)*float64(ui.EaseOut(float32(p)))
|
||||
}
|
||||
if value != st.to {
|
||||
from := at()
|
||||
slog.Debug("mujica: count up", "from", from, "to", value)
|
||||
st.from, st.to, st.start = from, value, now
|
||||
}
|
||||
shown := at()
|
||||
if shown != st.to {
|
||||
c.AnimationFrame()
|
||||
}
|
||||
return row.Children(func() { ui.Text(c, format(shown)).FontFeatures("tnum").SingleLine() })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// LoaderOptions configure the loaders: DotsLoader, BarsLoader, PulseLoader, OrbitLoader and WaveLoader.
|
||||
type LoaderOptions struct {
|
||||
// Size is the mark's height in DIPs; zero means 16.
|
||||
Size float32
|
||||
// Label is read out, and shown with ShowLabel; empty means "Loading".
|
||||
Label string
|
||||
ShowLabel bool
|
||||
// Color paints the mark; zero uses AccentText.
|
||||
Color ui.Color
|
||||
}
|
||||
|
||||
// loaderDraw paints a loader at phase t in [0, 1); t is 0 and still is set under reduced motion.
|
||||
type loaderDraw func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool)
|
||||
|
||||
// loaderMark builds a loader of aspect w:1 that repaints only while painted and moving.
|
||||
func loaderMark(c *ui.Context, opts LoaderOptions, aspect float32, period int64, draw loaderDraw) *ui.Element {
|
||||
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
|
||||
panic("mujica: loader Size must be finite and not negative")
|
||||
}
|
||||
k := Tokens(c)
|
||||
size := opts.Size
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "loading")
|
||||
}
|
||||
col := opts.Color
|
||||
if col.A == 0 {
|
||||
col = k.AccentText
|
||||
}
|
||||
still := c.Preferences().ReduceMotion
|
||||
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleProgress).Label(label)
|
||||
row.Children(func() {
|
||||
ui.Box(c).Key(still).Size(size*aspect, size).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
t := 0.0
|
||||
if !still {
|
||||
t = float64(p.Now().UnixMilli()%period) / float64(period)
|
||||
p.AnimationFrame()
|
||||
}
|
||||
draw(p, r, t, col, still)
|
||||
})
|
||||
// Reduced motion shows the words, as the mark stands still.
|
||||
if opts.ShowLabel || still {
|
||||
ui.Text(c, label).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// loaderWave is a smooth 0..1 rise and fall over phase t.
|
||||
func loaderWave(t float64) float32 { return float32(0.5 - 0.5*math.Cos(2*math.Pi*t)) }
|
||||
|
||||
// DotsLoader shows three dots lighting up in turn.
|
||||
func DotsLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 2.5, 1200, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
d := r.H * 0.36
|
||||
for i := range 3 {
|
||||
a := float32(1)
|
||||
if !still {
|
||||
a = 0.3 + 0.7*loaderWave(t-float64(i)/6)
|
||||
}
|
||||
var dot ui.Path
|
||||
cx := r.X + r.W*(float32(i)+0.5)/3
|
||||
dot.Circle(cx, r.Y+r.H/2, d/2)
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// EvalCase is one evaluation case and its outcome.
|
||||
type EvalCase struct {
|
||||
ID string
|
||||
Name string
|
||||
Score float64
|
||||
Passed bool
|
||||
}
|
||||
|
||||
// EvalResultTableOptions configure an EvalResultTable.
|
||||
type EvalResultTableOptions struct {
|
||||
// Decimals of scores; zero means 2.
|
||||
Decimals int
|
||||
Status DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// EvalResultTableResult is a built EvalResultTable and its events.
|
||||
type EvalResultTableResult struct {
|
||||
Table DataTableView
|
||||
diff string
|
||||
}
|
||||
|
||||
// Diff returns the case whose differences the user asked to see this frame.
|
||||
func (r EvalResultTableResult) Diff() (string, bool) { return r.diff, r.diff != "" }
|
||||
|
||||
// EvalResultTable lists eval cases with score and pass or fail, sortable, with a pass summary; give it a height.
|
||||
func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, opts EvalResultTableOptions) EvalResultTableResult {
|
||||
if opts.Decimals < 0 {
|
||||
panic("mujica: EvalResultTable Decimals is negative")
|
||||
}
|
||||
dec := opts.Decimals
|
||||
if dec == 0 {
|
||||
dec = 2
|
||||
}
|
||||
passed := 0
|
||||
for _, ec := range cases {
|
||||
if ec.ID == "" || math.IsNaN(ec.Score) || math.IsInf(ec.Score, 0) {
|
||||
panic("mujica: EvalCase needs an ID and a finite Score")
|
||||
}
|
||||
if ec.Passed {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var res EvalResultTableResult
|
||||
verdict := func(ec EvalCase) string {
|
||||
if ec.Passed {
|
||||
return msg(c, "eval_result_table.pass")
|
||||
}
|
||||
return msg(c, "eval_result_table.fail")
|
||||
}
|
||||
cols := []DataColumn[EvalCase]{
|
||||
{ID: "name", Title: msg(c, "eval_result_table.case"), Sortable: true, Compare: func(a, b EvalCase) int { return cmp.Compare(a.Name, b.Name) }, Text: func(e EvalCase) string { return e.Name }},
|
||||
{ID: "score", Title: msg(c, "eval_result_table.score"), Width: 90, Align: ui.End, Sortable: true, Compare: func(a, b EvalCase) int { return cmp.Compare(a.Score, b.Score) },
|
||||
Text: func(e EvalCase) string { return strconv.FormatFloat(e.Score, 'f', dec, 64) }},
|
||||
{ID: "result", Title: msg(c, "eval_result_table.result"), Width: 110, Sortable: true,
|
||||
Compare: func(a, b EvalCase) int { return cmp.Compare(evalRank(a), evalRank(b)) }, Text: verdict,
|
||||
Cell: func(e EvalCase) {
|
||||
icon, col := "circle-check", k.Success
|
||||
if !e.Passed {
|
||||
icon, col = "circle-x", k.Danger
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(col).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, verdict(e)).FontWeight(600)
|
||||
})
|
||||
}},
|
||||
{ID: "diff", Title: "", Width: 48, Text: func(EvalCase) string { return "" },
|
||||
Cell: func(e EvalCase) {
|
||||
b := iconAction(c, icons.Must("arrow-left-right"), fmt.Sprintf(msg(c, "eval_result_table.diff"), e.Name)).Tooltip(msg(c, "eval_result_table.view"))
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
res.diff = e.ID
|
||||
slog.Debug("mujica: eval result diff", "case", e.ID)
|
||||
}
|
||||
}},
|
||||
}
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2)
|
||||
col.Children(func() {
|
||||
if len(cases) > 0 {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "eval_result_table.summary"), passed, len(cases))).FontWeight(600)
|
||||
ui.Box(c).Height(6).Width(160).Radius(1).Background(k.Danger.Mix(k.Surface, 0.6)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(passed) / float32(len(cases)), H: r.H}, k.Success, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
res.Table = DataTable(c, s, cases, DataTableOptions[EvalCase, string]{Columns: cols, Key: func(e EvalCase) string { return e.ID },
|
||||
Empty: msg(c, "eval_result_table.empty"), Status: opts.Status, Error: opts.Error})
|
||||
res.Table.Element.Grow(1)
|
||||
})
|
||||
res.Table.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
func evalRank(e EvalCase) int {
|
||||
if e.Passed {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// InterruptButtonOptions configure an InterruptButton.
|
||||
type InterruptButtonOptions struct {
|
||||
// Confirm asks before interrupting.
|
||||
Confirm bool
|
||||
// Interrupting shows the stop in progress until the run ends.
|
||||
Interrupting bool
|
||||
// Label replaces the built-in "Stop".
|
||||
Label string
|
||||
}
|
||||
|
||||
// InterruptButton stops a running agent; Changed reports the interrupt, confirmed when asked.
|
||||
func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) ChoiceResult {
|
||||
if opts.Interrupting && !running {
|
||||
panic("mujica: InterruptButton cannot be Interrupting while not running")
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "interrupt_button.stop")
|
||||
}
|
||||
shown := label
|
||||
if opts.Interrupting {
|
||||
shown = msg(c, "interrupt_button.stopping")
|
||||
}
|
||||
live := running && !opts.Interrupting
|
||||
var res ChoiceResult
|
||||
wrap := ui.Row(c).Shrink(0)
|
||||
res.Element = wrap
|
||||
wrap.Children(func() {
|
||||
b := Button(c, shown, ButtonOptions{Variant: Danger, Icon: icons.Must("circle-x"), Loading: opts.Interrupting, Disabled: !running})
|
||||
open := ui.Local(wrap, "confirm", func() bool { return false })
|
||||
if !live || b.IsDisabled() {
|
||||
*open = false
|
||||
}
|
||||
clicked := b.Clicked() && live
|
||||
switch {
|
||||
case clicked && opts.Confirm:
|
||||
*open = true
|
||||
case clicked:
|
||||
res.changed = true
|
||||
}
|
||||
r := Popconfirm(c, b, open, PopconfirmOptions{Title: msg(c, "interrupt_button.confirm"),
|
||||
Description: msg(c, "interrupt_button.detail"), ConfirmLabel: label, CancelLabel: msg(c, "interrupt_button.keep"), Danger: true})
|
||||
if r.Confirmed && live {
|
||||
res.changed = true
|
||||
}
|
||||
})
|
||||
if res.changed {
|
||||
slog.Debug("mujica: interrupt button interrupt")
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// LayoutTransitionOptions configure a LayoutTransition.
|
||||
type LayoutTransitionOptions struct {
|
||||
// Duration of a change; zero means 200 ms.
|
||||
Duration time.Duration
|
||||
// Row lays the content in a row instead of a column.
|
||||
Row bool
|
||||
// Clip hides content outside the box while it resizes.
|
||||
Clip bool
|
||||
}
|
||||
|
||||
// LayoutTransition holds content whose size and place animate from where they were (FLIP).
|
||||
// Siblings that the change moves need a LayoutTransition of their own to move in step.
|
||||
func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) *ui.Element {
|
||||
if opts.Duration < 0 {
|
||||
panic("mujica: LayoutTransition Duration is negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = 200 * time.Millisecond
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if opts.Row {
|
||||
box.Row()
|
||||
}
|
||||
if opts.Clip {
|
||||
box.Clip()
|
||||
}
|
||||
box.Transition(ui.ElementTransition{Duration: d, Position: true, Size: true})
|
||||
if content != nil {
|
||||
box.Children(content)
|
||||
}
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// motionFrames builds view and returns its tester and a counter of built frames.
|
||||
func motionFrames(view func(c *ui.Context), w, h int) (*ui.Tester, *int) {
|
||||
n := new(int)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
*n++
|
||||
Use(c, Settings{})
|
||||
view(c)
|
||||
}, w, h)
|
||||
return tt, n
|
||||
}
|
||||
|
||||
// motionBusy reports whether one more frame keeps asking for frames.
|
||||
func motionBusy(tt *ui.Tester, n *int) bool {
|
||||
before := *n
|
||||
tt.Frame()
|
||||
return *n-before > 2
|
||||
}
|
||||
|
||||
// motionCount counts texts and names equal to s.
|
||||
func motionCount(tt *ui.Tester, s string) int {
|
||||
n := 0
|
||||
for _, x := range tt.Texts() {
|
||||
if x == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var loaderCases = map[string]func(c *ui.Context, o LoaderOptions) *ui.Element{
|
||||
"dots": DotsLoader, "bars": BarsLoader, "pulse": PulseLoader, "orbit": OrbitLoader, "wave": WaveLoader,
|
||||
}
|
||||
|
||||
func TestLoadersMoveStopAndLabel(t *testing.T) {
|
||||
for name, loader := range loaderCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
show := true
|
||||
tt, n := motionFrames(func(c *ui.Context) {
|
||||
if show {
|
||||
loader(c, LoaderOptions{Size: 32, Label: "Thinking"})
|
||||
}
|
||||
}, 200, 80)
|
||||
if _, ok := tt.Find("Thinking"); !ok {
|
||||
t.Fatal("loader not named")
|
||||
}
|
||||
if motionCount(tt, "Thinking") != 1 {
|
||||
t.Fatal("label shown without ShowLabel")
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) {
|
||||
t.Fatal("loader does not move")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if motionBusy(tt, n) || motionCount(tt, "Thinking") != 2 {
|
||||
t.Fatalf("reduced motion: still repainting or no static label: %q", tt.Texts())
|
||||
}
|
||||
a = encode(t, tt.Image())
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if !bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("loader moved under reduced motion")
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
show = false
|
||||
tt.Frame()
|
||||
if motionBusy(tt, n) {
|
||||
t.Fatal("hidden loader keeps repainting")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadersInvalidSize(t *testing.T) {
|
||||
for name, loader := range loaderCases {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: negative size did not panic", name)
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
loader(c, LoaderOptions{Size: -1})
|
||||
}, 100, 50, 1)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func loaderSnapshot(t *testing.T, name string, loader func(c *ui.Context, o LoaderOptions) *ui.Element) {
|
||||
snapshot(t, name, 480, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(24).AlignItems(ui.Center).Children(func() {
|
||||
loader(c, LoaderOptions{})
|
||||
loader(c, LoaderOptions{Size: 28, Label: "Thinking", ShowLabel: true})
|
||||
loader(c, LoaderOptions{Size: 20, Color: Tokens(c).Info})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDotsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "dots_loader", DotsLoader) }
|
||||
func TestBarsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "bars_loader", BarsLoader) }
|
||||
func TestPulseLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "pulse_loader", PulseLoader) }
|
||||
func TestOrbitLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "orbit_loader", OrbitLoader) }
|
||||
func TestWaveLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "wave_loader", WaveLoader) }
|
||||
@@ -0,0 +1,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", "下降"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// OrbitLoader moves a diamond around a ring of small diamonds, in Spinner's language.
|
||||
func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
cx, cy, rad := r.X+r.W/2, r.Y+r.H/2, r.W*0.36
|
||||
at := func(turn float64) (float32, float32) {
|
||||
a := 2*math.Pi*turn - math.Pi/2
|
||||
return internal.Snap(p, cx+rad*float32(math.Cos(a))), internal.Snap(p, cy+rad*float32(math.Sin(a)))
|
||||
}
|
||||
for i := range 8 {
|
||||
x, y := at(float64(i) / 8)
|
||||
p.FillPath(internal.Diamond(x, y, r.W*0.12), col.Alpha(0.3))
|
||||
}
|
||||
x, y := at(t)
|
||||
p.FillPath(internal.Diamond(x, y, internal.Snap(p, r.W*0.28)), col)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PresenceKind is how content comes and goes.
|
||||
type PresenceKind int
|
||||
|
||||
const (
|
||||
// PresenceFade fades in and out.
|
||||
PresenceFade PresenceKind = iota
|
||||
// PresenceSlide fades while moving up into place and down out.
|
||||
PresenceSlide
|
||||
// PresenceCollapse grows open and shrinks shut along the parent's direction.
|
||||
PresenceCollapse
|
||||
)
|
||||
|
||||
// PresenceOptions configure a Presence.
|
||||
type PresenceOptions struct {
|
||||
Kind PresenceKind
|
||||
// Duration is PopupDuration when zero.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// presenceMotion is where content of kind comes from and goes to.
|
||||
func presenceMotion(kind PresenceKind) *ui.Motion {
|
||||
switch kind {
|
||||
case PresenceFade:
|
||||
return &ui.Motion{}
|
||||
case PresenceSlide:
|
||||
return &ui.Motion{Y: 8}
|
||||
case PresenceCollapse:
|
||||
return &ui.Motion{Collapse: true, Opacity: 1}
|
||||
}
|
||||
panic("mujica: unknown PresenceKind")
|
||||
}
|
||||
|
||||
type presenceState struct{ shown bool }
|
||||
|
||||
// Presence builds content while show is true; it enters and exits with motion, removed once the exit ends.
|
||||
func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element {
|
||||
m := presenceMotion(opts.Kind)
|
||||
if opts.Duration < 0 {
|
||||
panic("mujica: Presence Duration is negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = theme.PopupDuration
|
||||
}
|
||||
holder := ui.Column(c).AlignItems(ui.Stretch).Shrink(0)
|
||||
st := ui.Local(holder, "presence", func() presenceState { return presenceState{shown: show} })
|
||||
if st.shown != show {
|
||||
st.shown = show
|
||||
slog.Debug("mujica: presence", "show", show)
|
||||
}
|
||||
holder.Children(func() {
|
||||
if !show {
|
||||
return
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch).Key("presence")
|
||||
box.Transition(ui.ElementTransition{Duration: d, Enter: m, Exit: m})
|
||||
box.Children(content)
|
||||
})
|
||||
return holder
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// PulseLoader shows one dot breathing between 60% and full size; never a halo.
|
||||
func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element {
|
||||
return loaderMark(c, opts, 1, 1600, func(p *ui.Painter, r ui.Rect, t float64, col ui.Color, still bool) {
|
||||
s, a := float32(1), float32(1)
|
||||
if !still {
|
||||
w := loaderWave(t)
|
||||
s, a = 0.6+0.4*w, 0.55+0.45*w
|
||||
}
|
||||
var dot ui.Path
|
||||
dot.Circle(r.X+r.W/2, r.Y+r.H/2, internal.Snap(p, r.H*0.4*s))
|
||||
p.FillPath(&dot, col.Alpha(a))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ReorderTransitionOptions configure a ReorderTransition.
|
||||
type ReorderTransitionOptions struct {
|
||||
// Duration of a move; zero means 200 ms.
|
||||
Duration time.Duration
|
||||
// Gap between items in DIPs.
|
||||
Gap float32
|
||||
}
|
||||
|
||||
// ReorderTransition lays out keyed items in a column; when their order changes they slide to their new places.
|
||||
// Items added fade in and items removed fade out.
|
||||
func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element {
|
||||
if opts.Duration < 0 || opts.Gap < 0 {
|
||||
panic("mujica: ReorderTransition Duration and Gap must not be negative")
|
||||
}
|
||||
d := opts.Duration
|
||||
if d == 0 {
|
||||
d = 200 * time.Millisecond
|
||||
}
|
||||
seen := make(map[K]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
if seen[k] {
|
||||
panic(fmt.Sprintf("mujica: ReorderTransition duplicate key %v", k))
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
move := ui.ElementTransition{Duration: d, Position: true, Enter: &ui.Motion{}, Exit: &ui.Motion{}}
|
||||
col := ui.Column(c).AlignItems(ui.Stretch).Gap(opts.Gap)
|
||||
col.Children(func() {
|
||||
for _, k := range keys {
|
||||
ui.Column(c).Key(k).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) })
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ShimmerTextOptions configure a ShimmerText.
|
||||
type ShimmerTextOptions struct {
|
||||
// Active sweeps the light; false shows plain muted text.
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ShimmerText shows a status such as "Thinking" with light sweeping across its letters.
|
||||
func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element {
|
||||
if text == "" {
|
||||
panic("mujica: ShimmerText needs text")
|
||||
}
|
||||
k := Tokens(c)
|
||||
still := c.Preferences().ReduceMotion
|
||||
if !opts.Active || still {
|
||||
e := ui.Text(c, text).TextColor(k.TextMuted)
|
||||
if opts.Active {
|
||||
e.Role(ui.RoleStatus)
|
||||
}
|
||||
return e
|
||||
}
|
||||
runes := []rune(text)
|
||||
size := c.Theme().FontSize
|
||||
// The text keeps its layout and name; the sweep is painted over it.
|
||||
e := ui.Text(c, text).TextColor(ui.Transparent).Role(ui.RoleStatus).SingleLine()
|
||||
e.DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
p.AnimationFrame()
|
||||
t := float32(p.Now().UnixMilli()%1800) / 1800
|
||||
at := t*float32(len(runes)+6) - 3
|
||||
spans := make([]ui.Span, len(runes))
|
||||
for i, ch := range runes {
|
||||
d := max(0, 1-abs32(float32(i)-at)/3)
|
||||
spans[i] = ui.Span{Text: string(ch), Size: size, Color: k.TextMuted.Mix(k.Text, d)}
|
||||
}
|
||||
p.RichText(r.X, r.Y, 0, spans...)
|
||||
})
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// StaggerOptions configure a Stagger.
|
||||
type StaggerOptions struct {
|
||||
// Interval between items; zero means 60 ms.
|
||||
Interval time.Duration
|
||||
// Duration of each item's entry; zero means PopupDuration.
|
||||
Duration time.Duration
|
||||
// Horizontal lays items in a row.
|
||||
Horizontal bool
|
||||
}
|
||||
|
||||
type staggerState struct {
|
||||
born time.Time
|
||||
shown int
|
||||
done bool
|
||||
}
|
||||
|
||||
// Stagger builds n items, each entering after the one before, once when it first appears.
|
||||
// Items added after the sequence ends appear with their own entry.
|
||||
func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.Element {
|
||||
if n < 0 || opts.Interval < 0 || opts.Duration < 0 {
|
||||
panic("mujica: Stagger needs non-negative n, Interval and Duration")
|
||||
}
|
||||
every, d := opts.Interval, opts.Duration
|
||||
if every == 0 {
|
||||
every = 60 * time.Millisecond
|
||||
}
|
||||
if d == 0 {
|
||||
d = theme.PopupDuration
|
||||
}
|
||||
box := ui.Column(c).AlignItems(ui.Stretch)
|
||||
if opts.Horizontal {
|
||||
box.Row().AlignItems(ui.Start)
|
||||
}
|
||||
st := ui.Local(box, "stagger", func() *staggerState { return &staggerState{born: c.Now()} })
|
||||
s := *st
|
||||
upto := n
|
||||
if !s.done && !c.Preferences().ReduceMotion {
|
||||
upto = min(n, 1+int(c.Now().Sub(s.born)/every))
|
||||
if upto < n {
|
||||
c.After(s.born.Add(time.Duration(upto) * every).Sub(c.Now()))
|
||||
} else {
|
||||
s.done = true
|
||||
slog.Debug("mujica: stagger done", "items", n)
|
||||
}
|
||||
}
|
||||
if c.Preferences().ReduceMotion {
|
||||
s.done = true
|
||||
}
|
||||
enter := &ui.Motion{Y: 8}
|
||||
if opts.Horizontal {
|
||||
enter = &ui.Motion{X: 8}
|
||||
}
|
||||
box.Children(func() {
|
||||
for i := range upto {
|
||||
ui.Column(c).Key(i).AlignItems(ui.Stretch).
|
||||
Transition(ui.ElementTransition{Duration: d, Enter: enter}).Children(func() { item(i) })
|
||||
}
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// TokenUsage is the tokens one session or step used.
|
||||
type TokenUsage struct {
|
||||
Label string
|
||||
Input, Output int
|
||||
}
|
||||
|
||||
// TokenUsageChartOptions configure a TokenUsageChart.
|
||||
type TokenUsageChartOptions struct {
|
||||
// Frame names, sizes and states the chart; an empty Label uses the built-in name.
|
||||
Frame ChartFrame
|
||||
// Line draws input and output as lines instead of stacked bars.
|
||||
Line bool
|
||||
}
|
||||
|
||||
type tokenUsageSeries struct {
|
||||
src *TokenUsage
|
||||
n int
|
||||
labels []string
|
||||
series []ChartSeries
|
||||
}
|
||||
|
||||
// TokenUsageChart shows input and output tokens per session or step, as stacked bars or lines.
|
||||
func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) ChartResult {
|
||||
if opts.Frame.Label == "" {
|
||||
opts.Frame.Label = msg(c, "token_usage_chart.label")
|
||||
}
|
||||
holder := ui.Column(c).AlignItems(ui.Stretch)
|
||||
cache := ui.Local(holder, "series", func() *tokenUsageSeries { return &tokenUsageSeries{} })
|
||||
s := *cache
|
||||
var first *TokenUsage
|
||||
if len(usage) > 0 {
|
||||
first = &usage[0]
|
||||
}
|
||||
if s.series == nil || s.src != first || s.n != len(usage) {
|
||||
in, out := make([]float64, len(usage)), make([]float64, len(usage))
|
||||
s.labels = make([]string, len(usage))
|
||||
for i, u := range usage {
|
||||
if u.Input < 0 || u.Output < 0 {
|
||||
panic("mujica: TokenUsage counts must not be negative")
|
||||
}
|
||||
in[i], out[i], s.labels[i] = float64(u.Input), float64(u.Output), u.Label
|
||||
}
|
||||
s.series = []ChartSeries{{Name: msg(c, "token_usage_chart.input"), Values: in}, {Name: msg(c, "token_usage_chart.output"), Values: out}}
|
||||
s.src, s.n = first, len(usage)
|
||||
}
|
||||
format := func(v float64) string { return strconv.FormatFloat(v, 'f', 0, 64) }
|
||||
var res ChartResult
|
||||
holder.Children(func() {
|
||||
if opts.Line {
|
||||
res = LineChart(c, s.series, LineChartOptions{Frame: opts.Frame, Format: format})
|
||||
return
|
||||
}
|
||||
res = BarChart(c, s.labels, s.series, BarChartOptions{Frame: opts.Frame, Stacked: true, Format: format})
|
||||
})
|
||||
res.Element = holder
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||