From 4b8fdf8df754c64c6bf4ec66c95f243b31cd62e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 15:51:57 -0400 Subject: [PATCH] WIP components (as built) --- bars_loader.go | 2 +- bars_loader_test.go | 7 + blink_highlight.go | 14 +- blink_highlight_test.go | 15 + cost_breakdown.go | 29 +- cost_breakdown_test.go | 57 ++++ count_up.go | 17 +- count_up_test.go | 45 +++ docs/components/281-interrupt-button.md | 51 ++++ docs/components/282-token-usage-chart.md | 54 ++++ docs/components/283-cost-breakdown.md | 60 ++++ docs/components/284-trace-viewer.md | 64 +++++ docs/components/285-eval-result-table.md | 63 +++++ docs/components/286-tool-registry-panel.md | 62 +++++ docs/components/287-dots-loader.md | 48 ++++ docs/components/288-bars-loader.md | 47 ++++ docs/components/289-pulse-loader.md | 47 ++++ docs/components/290-orbit-loader.md | 47 ++++ docs/components/291-wave-loader.md | 47 ++++ docs/components/292-shimmer.md | 50 ++++ docs/components/293-count-up.md | 47 ++++ docs/components/294-typewriter.md | 49 ++++ docs/components/295-shimmer-text.md | 46 ++++ docs/components/296-stagger.md | 50 ++++ docs/components/297-reorder-transition.md | 47 ++++ docs/components/298-layout-transition.md | 52 ++++ docs/components/299-presence.md | 48 ++++ docs/components/300-blink-highlight.md | 47 ++++ dots_loader.go | 14 +- dots_loader_test.go | 7 + eval_result_table.go | 52 ++-- eval_result_table_test.go | 67 +++++ example_motion_test.go | 221 +++++++++++++++ examples/gallery/pages_motion.go | 257 +++++++++++++++++- examples/gallery/pages_motion_fx.go | 220 +++++++++++++++ examples/gallery/pages_motion_ops.go | 241 ++++++++++++++++ examples/gallery/pages_motion_test.go | 60 ++++ interrupt_button.go | 6 +- interrupt_button_test.go | 24 ++ layout_transition.go | 13 +- layout_transition_test.go | 55 ++++ loaders_test.go | 78 +++--- messages_motion.go | 7 + motion_delivery_test.go | 57 ++++ orbit_loader.go | 4 +- orbit_loader_test.go | 7 + presence.go | 10 +- presence_test.go | 85 ++++++ pulse_loader.go | 4 +- pulse_loader_test.go | 7 + reorder_transition.go | 35 ++- reorder_transition_test.go | 80 ++++++ shimmer.go | 4 +- shimmer_test.go | 44 --- shimmer_text.go | 8 +- shimmer_text_test.go | 76 ++++++ stagger.go | 15 +- stagger_test.go | 71 +++++ .../darwin/tool_registry_panel-dark.png | Bin 22686 -> 22674 bytes .../darwin/tool_registry_panel-light.png | Bin 22522 -> 22321 bytes token_usage_chart.go | 39 ++- token_usage_chart_test.go | 44 +++ tool_registry_panel.go | 82 +++--- tool_registry_panel_test.go | 58 ++++ trace_viewer.go | 58 ++-- trace_viewer_test.go | 76 ++++++ transitions_test.go | 210 -------------- typewriter.go | 37 ++- typewriter_test.go | 55 +++- wave_loader.go | 2 +- wave_loader_test.go | 7 + 71 files changed, 3241 insertions(+), 468 deletions(-) create mode 100644 bars_loader_test.go create mode 100644 docs/components/281-interrupt-button.md create mode 100644 docs/components/282-token-usage-chart.md create mode 100644 docs/components/283-cost-breakdown.md create mode 100644 docs/components/284-trace-viewer.md create mode 100644 docs/components/285-eval-result-table.md create mode 100644 docs/components/286-tool-registry-panel.md create mode 100644 docs/components/287-dots-loader.md create mode 100644 docs/components/288-bars-loader.md create mode 100644 docs/components/289-pulse-loader.md create mode 100644 docs/components/290-orbit-loader.md create mode 100644 docs/components/291-wave-loader.md create mode 100644 docs/components/292-shimmer.md create mode 100644 docs/components/293-count-up.md create mode 100644 docs/components/294-typewriter.md create mode 100644 docs/components/295-shimmer-text.md create mode 100644 docs/components/296-stagger.md create mode 100644 docs/components/297-reorder-transition.md create mode 100644 docs/components/298-layout-transition.md create mode 100644 docs/components/299-presence.md create mode 100644 docs/components/300-blink-highlight.md create mode 100644 dots_loader_test.go create mode 100644 example_motion_test.go create mode 100644 examples/gallery/pages_motion_fx.go create mode 100644 examples/gallery/pages_motion_ops.go create mode 100644 examples/gallery/pages_motion_test.go create mode 100644 layout_transition_test.go create mode 100644 motion_delivery_test.go create mode 100644 orbit_loader_test.go create mode 100644 presence_test.go create mode 100644 pulse_loader_test.go create mode 100644 reorder_transition_test.go create mode 100644 shimmer_text_test.go create mode 100644 stagger_test.go delete mode 100644 transitions_test.go create mode 100644 wave_loader_test.go diff --git a/bars_loader.go b/bars_loader.go index fbdab80..23fd898 100644 --- a/bars_loader.go +++ b/bars_loader.go @@ -8,7 +8,7 @@ import ( // 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) { + return loaderMark(c, opts, "bars_loader", 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) diff --git a/bars_loader_test.go b/bars_loader_test.go new file mode 100644 index 0000000..1f9392f --- /dev/null +++ b/bars_loader_test.go @@ -0,0 +1,7 @@ +package mujica + +import "testing" + +func TestBarsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "bars_loader", BarsLoader) } + +func TestBarsLoaderBehavior(t *testing.T) { testLoaderBehavior(t, BarsLoader) } diff --git a/blink_highlight.go b/blink_highlight.go index 31b351b..1ad449a 100644 --- a/blink_highlight.go +++ b/blink_highlight.go @@ -16,7 +16,7 @@ import ( 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 of the fade; zero means StateDuration. Duration time.Duration } @@ -27,15 +27,14 @@ type blinkState struct { 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. +// BlinkHighlight tints value once on change and marks its direction. 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 + d = theme.StateDuration } format := opts.Format if format == nil { @@ -55,6 +54,9 @@ func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *u slog.Debug("mujica: blink highlight", "from", st.value, "to", value) } st.value, st.set = value, true + if c.Preferences().ReduceMotion { + st.at = time.Time{} + } col := k.Success icon, word := "arrow-up", "blink_highlight.up" if st.dir < 0 { @@ -65,10 +67,10 @@ func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *u name += ", " + msg(c, word) } row.Label(name) - if dur := Motion(c, d); st.dir != 0 && dur > 0 { + if dur := Motion(c, d); st.dir != 0 && !st.at.IsZero() && dur > 0 { if p := float32(now.Sub(st.at)) / float32(dur); p < 1 { row.Background(col.Alpha(0.22 * (1 - p))) - c.AnimationFrame() + row.DrawOver(func(*ui.Painter, ui.Rect) { c.AnimationFrame() }) } } row.Children(func() { diff --git a/blink_highlight_test.go b/blink_highlight_test.go index ebc9e59..d0d8938 100644 --- a/blink_highlight_test.go +++ b/blink_highlight_test.go @@ -69,3 +69,18 @@ func TestBlinkHighlightSnapshot(t *testing.T) { *v, *w = 103.75, 86.5 }, func(tt *ui.Tester) { tt.Frame() }) } + +func TestBlinkHighlightRestoredMotionKeepsStaticMark(t *testing.T) { + value := 1.0 + tt, n := motionFrames(func(c *ui.Context) { BlinkHighlight(c, value, BlinkHighlightOptions{Duration: 5 * time.Second}) }, 300, 100) + value = 2 + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.SetPreferences(ui.Preferences{TextScale: 1}) + if motionBusy(tt, n) { + t.Fatal("old highlight replayed after restoring motion") + } + if _, ok := tt.Find("2.00, up"); !ok { + t.Fatal("direction mark lost") + } +} diff --git a/cost_breakdown.go b/cost_breakdown.go index 7b88a78..f086929 100644 --- a/cost_breakdown.go +++ b/cost_breakdown.go @@ -32,6 +32,8 @@ const ( // CostBreakdownOptions configure a CostBreakdown. type CostBreakdownOptions struct { GroupBy CostGroup + // Version changes when items are edited in place. + Version int // Currency precedes amounts; empty means "$". Currency string // Decimals of amounts; zero means 2. @@ -40,13 +42,15 @@ type CostBreakdownOptions struct { // 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 + Table DataTableState[string] + rows []costRow + total costRow + src *CostItem + n int + group CostGroup + built bool + version int + unnamed string } type costRow struct { @@ -56,7 +60,7 @@ type costRow struct { cost float64 } -// CostBreakdown sums costs by model, tool or step in a sortable table with a total; give it a height. +// CostBreakdown totals grouped costs; give the element 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") @@ -72,9 +76,11 @@ func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts 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")) + unnamed := msg(c, "cost_breakdown.unnamed") + if !s.built || s.src != first || s.n != len(items) || s.group != opts.GroupBy || s.version != opts.Version || s.unnamed != unnamed { + s.rows, s.total = costSum(items, opts.GroupBy, unnamed) s.src, s.n, s.group, s.built = first, len(items), opts.GroupBy, true + s.version, s.unnamed = opts.Version, unnamed 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) } @@ -142,6 +148,9 @@ func costSum(items []CostItem, by CostGroup, unnamed string) ([]costRow, costRow rows = append(rows, costRow{name: name}) } for _, r := range []*costRow{&rows[i], &total} { + if r.input > math.MaxInt-it.InputTokens || r.output > math.MaxInt-it.OutputTokens || math.IsInf(r.cost+it.Cost, 0) { + panic("mujica: CostBreakdown total exceeds numeric range") + } r.calls++ r.input += it.InputTokens r.output += it.OutputTokens diff --git a/cost_breakdown_test.go b/cost_breakdown_test.go index 0445900..b3a2193 100644 --- a/cost_breakdown_test.go +++ b/cost_breakdown_test.go @@ -1,6 +1,8 @@ package mujica import ( + "math" + "strconv" "testing" "github.com/egoist/mygo/ui" @@ -93,3 +95,58 @@ func TestCostBreakdownSnapshot(t *testing.T) { }) }) } + +func TestCostBreakdownVersionAndToolGroups(t *testing.T) { + var s CostBreakdownState + items := costSample() + version := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CostBreakdown(c, &s, items, CostBreakdownOptions{GroupBy: CostByTool, Version: version}).Element.Height(300) + }, 800, 400) + if len(s.rows) != 2 || s.rows[0].name != "search" || s.rows[0].calls != 2 { + t.Fatalf("tool groups %+v", s.rows) + } + items[0].Cost = 1.30 + version++ + tt.Frame() + if !tt.HasText("$2.50") { + t.Fatalf("stale total: %q", tt.Texts()) + } +} + +func TestCostBreakdownLargeData(t *testing.T) { + items := make([]CostItem, 100000) + for i := range items { + items[i] = CostItem{Model: strconv.Itoa(i), Cost: 1} + } + var s CostBreakdownState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CostBreakdown(c, &s, items, CostBreakdownOptions{}).Element.Height(300) + }, 800, 400) + if s.total.cost != 100000 || s.Table.Rows() != 100000 || len(tt.Texts()) > 300 { + t.Fatalf("rows %d texts %d total %v", s.Table.Rows(), len(tt.Texts()), s.total.cost) + } + a, b := s.Table.rows.Visible() + if b-a > 30 { + t.Fatalf("built %d rows", b-a) + } +} + +func TestCostSumOverflowPanics(t *testing.T) { + for _, items := range [][]CostItem{ + {{InputTokens: math.MaxInt}, {InputTokens: 1}}, + {{OutputTokens: math.MaxInt}, {OutputTokens: 1}}, + {{Cost: math.MaxFloat64}, {Cost: math.MaxFloat64}}, + } { + func() { + defer func() { + if recover() == nil { + t.Error("unrepresentable cost total accepted") + } + }() + costSum(items, CostByModel, "unnamed") + }() + } +} diff --git a/count_up.go b/count_up.go index 71b75be..651ca62 100644 --- a/count_up.go +++ b/count_up.go @@ -7,11 +7,13 @@ import ( "time" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) // CountUpOptions configure a CountUp. type CountUpOptions struct { - // Duration of a change; zero means 600 ms. + // Duration of a change; zero means StateDuration. Duration time.Duration // Decimals shown; Format overrides it. Decimals int @@ -22,10 +24,9 @@ type CountUpOptions struct { 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. +// CountUp counts from the last value to the new one. 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") @@ -35,7 +36,7 @@ func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element { } d := opts.Duration if d == 0 { - d = 600 * time.Millisecond + d = theme.StateDuration } format := opts.Format if format == nil { @@ -50,16 +51,20 @@ func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element { 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))) + eased := float64(ui.EaseOut(float32(p))) + return st.from*(1-eased) + st.to*eased } if value != st.to { from := at() slog.Debug("mujica: count up", "from", from, "to", value) st.from, st.to, st.start = from, value, now } + if dur == 0 { + st.from, st.start = st.to, now + } shown := at() if shown != st.to { - c.AnimationFrame() + row.DrawOver(func(*ui.Painter, ui.Rect) { c.AnimationFrame() }) } return row.Children(func() { ui.Text(c, format(shown)).FontFeatures("tnum").SingleLine() }) } diff --git a/count_up_test.go b/count_up_test.go index a5e2bc9..bd6fc21 100644 --- a/count_up_test.go +++ b/count_up_test.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "math" "strings" "testing" "time" @@ -83,3 +84,47 @@ func TestCountUpSnapshot(t *testing.T) { }) }) } + +func TestCountUpFiniteExtremeInterpolation(t *testing.T) { + value := -math.MaxFloat64 + tt, _ := motionFrames(func(c *ui.Context) { + CountUp(c, value, CountUpOptions{Duration: time.Second, Format: func(v float64) string { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Fatal("finite endpoints produced non-finite text") + } + return fmt.Sprintf("%g", v) + }}) + }, 300, 80) + value = math.MaxFloat64 + tt.Frame() +} + +func TestCountUpOffscreenStopsFrames(t *testing.T) { + value := 1.0 + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Height(80).Clip().Children(func() { + ui.Box(c).Size(10, 200).Shrink(0) + CountUp(c, value, CountUpOptions{Duration: time.Second}) + }) + }, 300, 80) + value = 2 + tt.Frame() + if motionBusy(tt, n) { + t.Fatal("offscreen CountUp requested continuous rebuilds") + } +} + +func TestCountUpRestoredMotionKeepsFinalValue(t *testing.T) { + value := 0.0 + shown := 0.0 + tt, n := motionFrames(func(c *ui.Context) { + CountUp(c, value, CountUpOptions{Duration: 5 * time.Second, Format: func(v float64) string { shown = v; return fmt.Sprintf("%g", v) }}) + }, 300, 100) + value = 100 + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.SetPreferences(ui.Preferences{TextScale: 1}) + if shown != 100 || motionBusy(tt, n) { + t.Fatalf("restored value %v", shown) + } +} diff --git a/docs/components/281-interrupt-button.md b/docs/components/281-interrupt-button.md new file mode 100644 index 0000000..1133545 --- /dev/null +++ b/docs/components/281-interrupt-button.md @@ -0,0 +1,51 @@ +# 281 InterruptButton 中断按钮 + +## 用途 + +中断正在运行的智能体:可选确认,中断进行中显示“正在停止”直到运行结束。 + +## 最小示例 + +```go +running, stopping := true, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.InterruptButton(c, running, mujica.InterruptButtonOptions{Confirm: true, Interrupting: stopping}) + if r.Changed() { + stopping = true // cancel the run, then clear running and stopping + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) ChoiceResult` + +## 参数 + +`InterruptButtonOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Confirm | bool | false | 点击后先弹出确认,确认后才报告中断。 | +| Interrupting | bool | false | 中断进行中:按钮保留尺寸并显示加载;running 为 false 时设为 true 会 panic。 | +| Label | string | 内置“停止” | 按钮文字,也是确认按钮的文字。 | + +`running` 为 false 时按钮禁用。 + +## 状态 + +- 危险按钮外观:DangerFill 底与 OnDanger 文字,图标为叉号。 +- 中断中:显示加载标记与“正在停止”,不再响应点击。 +- 确认弹层默认聚焦“继续运行”,Enter 不会误触中断。 + +## 事件 + +- `Changed()`:用户要求中断(需要确认时在确认后)。 + +## 键盘操作 + +- Tab 聚焦,Enter 或 Space 按下;确认弹层中 Tab 到“停止”再 Enter,Escape 取消。 + +## 限制 + +- 只报告中断请求,取消运行由调用方完成。 diff --git a/docs/components/282-token-usage-chart.md b/docs/components/282-token-usage-chart.md new file mode 100644 index 0000000..1e04edb --- /dev/null +++ b/docs/components/282-token-usage-chart.md @@ -0,0 +1,54 @@ +# 282 TokenUsageChart Token 用量图 + +## 用途 + +按会话或步骤显示输入与输出 token 用量:默认堆叠柱状图,也可切换为折线。 + +## 最小示例 + +```go +usage := []mujica.TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Write", Input: 2100, Output: 2900}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TokenUsageChart(c, usage, mujica.TokenUsageChartOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) ChartResult` + +## 参数 + +`TokenUsage` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Label | string | "" | 会话或步骤名,作为柱的类别。 | +| Input, Output | int | 0 | 输入、输出 token 数;为负 panic。 | + +`TokenUsageChartOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Frame | ChartFrame | 零值 | 图表名称、高度、加载/失败/空状态;Label 为空时用内置“Token 用量”。 | +| Line | bool | false | 折线代替堆叠柱。 | +| Version | int | 0 | 原地修改数据时递增;新切片或长度变化自动刷新。 | + +返回 `ChartResult`。序列在 usage 切片、长度或 Version 变化时重建。超过 256 项时,相邻项目汇总为最多 256 组,保留输入输出总数;柱类别显示首尾名称。 + +## 状态 + +- 输入、输出分色(取自图表调色板,对 Surface 至少 3:1),图例同时用形状区分。 +- 加载、失败、无数据状态与图表同尺寸。 + +## 事件 + +- `Changed()`:读数移到另一个标记;`Toggled()`:图例开关某个序列。 + +## 键盘操作 + +- Tab 聚焦图表,←/→ 在柱或点之间移动,读数显示并朗读。 + +## 限制 + +- 折线模式的横轴为序号,类别名只在柱状模式显示。 diff --git a/docs/components/283-cost-breakdown.md b/docs/components/283-cost-breakdown.md new file mode 100644 index 0000000..982741d --- /dev/null +++ b/docs/components/283-cost-breakdown.md @@ -0,0 +1,60 @@ +# 283 CostBreakdown 费用分解 + +## 用途 + +按模型、工具或步骤汇总调用次数、token 与费用,表格可排序,底部显示合计。 + +## 最小示例 + +```go +var state mujica.CostBreakdownState +items := []mujica.CostItem{ + {Model: "opus", Tool: "search", InputTokens: 1000, OutputTokens: 200, Cost: 0.30}, + {Model: "haiku", Tool: "search", InputTokens: 4000, OutputTokens: 500, Cost: 0.05}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostBreakdown(c, &state, items, mujica.CostBreakdownOptions{GroupBy: mujica.CostByModel}).Element.Height(260) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CostBreakdown(c *ui.Context, s *CostBreakdownState, items []CostItem, opts CostBreakdownOptions) DataTableView` + +## 参数 + +`CostItem` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Model, Tool, Step | string | "" | 分组依据;为空归入“(未命名)”。 | +| InputTokens, OutputTokens | int | 0 | token 数;为负 panic。 | +| Cost | float64 | 0 | 费用;为负或非有限值 panic。 | + +`CostBreakdownOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| GroupBy | CostGroup | CostByModel | CostByModel、CostByTool、CostByStep。 | +| Currency | string | "$" | 金额前缀。 | +| Decimals | int | 0(即 2) | 金额小数位;为负 panic。 | +| Version | int | 0 | 原地修改数据时递增;新切片或长度变化自动刷新。 | + +`CostBreakdownState.Table` 保存排序与选择。汇总只在 items 或分组方式变化时计算。给返回的 `Element` 一个高度。 + +## 状态 + +- 费用列含占比条与百分比,数字为等宽数字。 +- 合计行为衬线“合计”、token 总数与加粗金额,朗读完整合计。 + +## 事件 + +- 同 DataTable:`Changed()`、`SortChanged()`。 + +## 键盘操作 + +- 同 DataTable:↑/↓ 选择行,列标题可用键盘排序。 + +## 限制 + +- 金额以浮点汇总并按小数位显示,不做货币舍入规则。 diff --git a/docs/components/284-trace-viewer.md b/docs/components/284-trace-viewer.md new file mode 100644 index 0000000..065e2ed --- /dev/null +++ b/docs/components/284-trace-viewer.md @@ -0,0 +1,64 @@ +# 284 TraceViewer 调用链查看器 + +## 用途 + +以树显示调用链跨度,每行带耗时与时间轴条;选中跨度在下方查看属性。 + +## 最小示例 + +```go +var state mujica.TraceViewerState +spans := []mujica.TraceSpan{ + {ID: "run", Name: "agent.run", End: 900 * time.Millisecond}, + {ID: "plan", Parent: "run", Name: "llm.plan", Start: 10 * time.Millisecond, End: 210 * time.Millisecond, + Attrs: []mujica.TraceAttr{{Key: "model", Value: "opus"}}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TraceViewer(c, &state, spans, mujica.TraceViewerOptions{}).Element.Height(260) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts TraceViewerOptions) DataTableView` + +## 参数 + +`TraceSpan` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| ID | string | 必填 | 唯一标识;重复 panic。 | +| Parent | string | "" | 父跨度;不存在时作为根。 | +| Name | string | "" | 名称。 | +| Start, End | time.Duration | 0 | 相对调用链开始的偏移;需 0 ≤ Start ≤ End。 | +| Failed | bool | false | 失败。 | +| Attrs | []TraceAttr | nil | 属性键值。 | + +`TraceViewerOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Status | DataStatus | DataReady | 加载、失败状态。 | +| Error | string | "" | 失败说明。 | +| Version | int | 0 | 原地修改数据时递增;新切片或长度变化自动刷新。 | + +`TraceViewerState.Tree` 保存展开、排序与选择。索引只在 spans 变化时重建;父链成环时 panic。给返回的 `Element` 一个高度。 + +## 状态 + +- 时间轴条按整条调用链的跨度缩放,位置吸附像素。 +- 失败跨度:叉号图标、Danger 文字与空心时间条,不只靠颜色。 +- 属性面板显示开始、耗时与全部属性。 + +## 事件 + +- 同 TreeTable:`Changed()` 选择变化。 + +## 键盘操作 + +- ↑/↓ 选择,→ 展开,← 收起;输入名称开头字母跳转。 + +## 限制 + +- 只构建可见行(TreeTable 虚拟化);属性面板也按可见行虚拟化,独立滚动。 diff --git a/docs/components/285-eval-result-table.md b/docs/components/285-eval-result-table.md new file mode 100644 index 0000000..44d75ce --- /dev/null +++ b/docs/components/285-eval-result-table.md @@ -0,0 +1,63 @@ +# 285 EvalResultTable 评测结果表 + +## 用途 + +列出评测用例、得分与通过/失败,顶部显示通过数,每行可查看差异。 + +## 最小示例 + +```go +var state mujica.DataTableState[string] +cases := []mujica.EvalCase{{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, {ID: "c2", Name: "Refuses forgery", Score: 0.41}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EvalResultTable(c, &state, cases, mujica.EvalResultTableOptions{}) + r.Table.Element.Height(260) + if id, ok := r.Diff(); ok { + _ = id // open expected and actual output side by side + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, opts EvalResultTableOptions) EvalResultTableResult` + +## 参数 + +`EvalCase` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| ID | string | 必填 | 唯一标识。 | +| Name | string | "" | 用例名。 | +| Score | float64 | 0 | 得分;非有限值 panic。 | +| Passed | bool | false | 是否通过。 | + +`EvalResultTableOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Decimals | int | 0(即 2) | 得分小数位;为负 panic。 | +| Status | DataStatus | DataReady | 加载、失败状态。 | +| Error | string | "" | 失败说明。 | +| Version | int | 0 | 原地修改数据时递增;新切片或长度变化自动刷新。 | + +返回 `EvalResultTableResult`,含命名字段 `Element` 与 `Table`(DataTableView)。给 `Element` 一个高度。通过数与数据校验按数据版本缓存。 + +## 状态 + +- 结果列为图标加“通过/失败”文字与颜色。 +- 顶部“通过 n / m”与比例条。 + +## 事件 + +- `Diff()`:要查看差异的用例 ID。 +- `Table` 的 `Changed()`、`SortChanged()`。 + +## 键盘操作 + +- ↑/↓ 选择行;差异按钮用 Tab 到达,Enter 或 Space 触发。 + +## 限制 + +- 差异内容由调用方显示,例如用 DiffViewer。 diff --git a/docs/components/286-tool-registry-panel.md b/docs/components/286-tool-registry-panel.md new file mode 100644 index 0000000..853a105 --- /dev/null +++ b/docs/components/286-tool-registry-panel.md @@ -0,0 +1,62 @@ +# 286 ToolRegistryPanel 工具注册面板 + +## 用途 + +列出智能体可用的工具与启停开关,并预览所选工具的参数模式。 + +## 最小示例 + +```go +var state mujica.ListState[string] +tools := []mujica.AgentTool{{Name: "web_search", Description: "Search the web", Enabled: true, + Params: []mujica.ToolParam{{Name: "query", Type: "string", Required: true}}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolRegistryPanel(c, &state, tools, mujica.ToolRegistryPanelOptions{}) + r.Element.Height(260) + if name, on, ok := r.Toggled(); ok && name == tools[0].Name { + tools[0].Enabled = on + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ToolRegistryPanel(c *ui.Context, s *ListState[string], tools []AgentTool, opts ToolRegistryPanelOptions) ToolRegistryResult` + +## 参数 + +`AgentTool` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Name | string | 必填 | 唯一名称;重复 panic。 | +| Description | string | "" | 说明。 | +| Enabled | bool | false | 是否启用,由调用方持有。 | +| Params | []ToolParam | nil | 参数:名称、类型、必填、说明。 | + +`ToolRegistryPanelOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Disabled | bool | false | 禁用开关。 | +| Version | int | 0 | 原地修改数据时递增;新切片或长度变化自动刷新。 | + +给返回的 `Element` 一个高度;`List.Changed()` 报告工具选择。名称索引按数据版本缓存,参数预览独立滚动,按可见行虚拟化。 + +## 状态 + +- 开关使用 Switch,名称为“启用 工具名”。 +- 参数以等宽名称、Info 色类型与“必填”描边标签显示。 + +## 事件 + +- `Toggled()`:被切换的工具与新状态。 +- 列表选择变化时右侧预览随之更新。 + +## 键盘操作 + +- 列表 ↑/↓ 选择工具;开关用 Tab 到达,Space 切换。 + +## 限制 + +- 参数只做平铺预览,不展开嵌套对象。 diff --git a/docs/components/287-dots-loader.md b/docs/components/287-dots-loader.md new file mode 100644 index 0000000..0e741c9 --- /dev/null +++ b/docs/components/287-dots-loader.md @@ -0,0 +1,48 @@ +# 287 DotsLoader 三点加载 + +## 用途 + +三个圆点依次变亮的加载标记,只在加载期间显示。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DotsLoader(c, mujica.LoaderOptions{Label: "Thinking", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func DotsLoader(c *ui.Context, opts LoaderOptions) *ui.Element` + +## 参数 + +`LoaderOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Size | float32 | 0(即 16) | 标记高度;为负或无穷 panic。 | +| Label | string | 内置“加载中” | 朗读文字。 | +| ShowLabel | bool | false | 在标记旁显示文字。 | +| Color | ui.Color | AccentText | 标记颜色。 | + +五个加载器共用 `LoaderOptions`。 + +## 状态 + +- 只在绘制时请求下一帧:不再构建或被隐藏后停止重绘。 +- 减少动画:标记静止,并始终显示文字说明。 +- 无弹跳、无发光。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无:不获取焦点,以进度角色朗读。 + +## 限制 + +- 无。 diff --git a/docs/components/288-bars-loader.md b/docs/components/288-bars-loader.md new file mode 100644 index 0000000..57355b3 --- /dev/null +++ b/docs/components/288-bars-loader.md @@ -0,0 +1,47 @@ +# 288 BarsLoader 条形加载 + +## 用途 + +四根竖条依次升降的加载标记。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarsLoader(c, mujica.LoaderOptions{Size: 20, Label: "Indexing"}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func BarsLoader(c *ui.Context, opts LoaderOptions) *ui.Element` + +## 参数 + +`LoaderOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Size | float32 | 0(即 16) | 标记高度。 | +| Label | string | 内置“加载中” | 朗读文字。 | +| ShowLabel | bool | false | 显示文字。 | +| Color | ui.Color | AccentText | 颜色。 | + +见 DotsLoader。 + +## 状态 + +- 条高吸附像素;只在绘制时动画。 +- 减少动画:静止并显示文字。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 无。 diff --git a/docs/components/289-pulse-loader.md b/docs/components/289-pulse-loader.md new file mode 100644 index 0000000..6646e34 --- /dev/null +++ b/docs/components/289-pulse-loader.md @@ -0,0 +1,47 @@ +# 289 PulseLoader 呼吸加载 + +## 用途 + +单个圆点在 60% 到 100% 之间缓慢呼吸的加载标记,强度受限,不发光。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PulseLoader(c, mujica.LoaderOptions{Label: "Listening", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func PulseLoader(c *ui.Context, opts LoaderOptions) *ui.Element` + +## 参数 + +`LoaderOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Size | float32 | 0(即 16) | 标记高度。 | +| Label | string | 内置“加载中” | 朗读文字。 | +| ShowLabel | bool | false | 显示文字。 | +| Color | ui.Color | AccentText | 颜色。 | + +见 DotsLoader。 + +## 状态 + +- 透明度 0.55–1、尺寸 0.6–1,无光晕。 +- 减少动画:静止并显示文字。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 无。 diff --git a/docs/components/290-orbit-loader.md b/docs/components/290-orbit-loader.md new file mode 100644 index 0000000..0238440 --- /dev/null +++ b/docs/components/290-orbit-loader.md @@ -0,0 +1,47 @@ +# 290 OrbitLoader 环绕加载 + +## 用途 + +一枚菱形沿一圈小菱形环绕,与 Spinner 同一视觉语言。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OrbitLoader(c, mujica.LoaderOptions{Size: 24, Label: "Connecting"}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func OrbitLoader(c *ui.Context, opts LoaderOptions) *ui.Element` + +## 参数 + +`LoaderOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Size | float32 | 0(即 16) | 标记高度。 | +| Label | string | 内置“加载中” | 朗读文字。 | +| ShowLabel | bool | false | 显示文字。 | +| Color | ui.Color | AccentText | 颜色。 | + +见 DotsLoader。 + +## 状态 + +- 移动的菱形坐标吸附像素。 +- 减少动画:菱形停在顶端并显示文字。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 无。 diff --git a/docs/components/291-wave-loader.md b/docs/components/291-wave-loader.md new file mode 100644 index 0000000..d60949a --- /dev/null +++ b/docs/components/291-wave-loader.md @@ -0,0 +1,47 @@ +# 291 WaveLoader 波形加载 + +## 用途 + +一条向右流动的正弦线加载标记。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaveLoader(c, mujica.LoaderOptions{Label: "Speaking", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func WaveLoader(c *ui.Context, opts LoaderOptions) *ui.Element` + +## 参数 + +`LoaderOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Size | float32 | 0(即 16) | 标记高度,宽为 2.5 倍。 | +| Label | string | 内置“加载中” | 朗读文字。 | +| ShowLabel | bool | false | 显示文字。 | +| Color | ui.Color | AccentText | 颜色。 | + +见 DotsLoader。 + +## 状态 + +- 路径点吸附像素。 +- 减少动画:静止的波形并显示文字。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 无。 diff --git a/docs/components/292-shimmer.md b/docs/components/292-shimmer.md new file mode 100644 index 0000000..563be7e --- /dev/null +++ b/docs/components/292-shimmer.md @@ -0,0 +1,50 @@ +# 292 Shimmer 占位扫光 + +## 用途 + +加载中显示带扫光的占位块,加载完成后构建内容。 + +## 最小示例 + +```go +loading := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Shimmer(c, loading, mujica.ShimmerOptions{Height: 48}, func() { + ui.Text(c, "Report ready") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) *ui.Element` + +## 参数 + +`ShimmerOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Width | float32 | 0(填满) | 宽度;为负 panic。 | +| Height | float32 | 0(即 ControlHeight) | 高度。 | +| Label | string | 内置“加载中” | 加载时朗读的文字。 | + +`loading` 为 false 时只构建 content。 + +## 状态 + +- 扫光为一条半透明 Surface 色带,位置吸附像素,只在绘制时动画。 +- 减少动画:静态填充。 +- 加载结束后不再重绘。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 只用于加载中,不作装饰。 diff --git a/docs/components/293-count-up.md b/docs/components/293-count-up.md new file mode 100644 index 0000000..686a205 --- /dev/null +++ b/docs/components/293-count-up.md @@ -0,0 +1,47 @@ +# 293 CountUp 数字过渡 + +## 用途 + +数值变化时从旧值过渡到新值。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CountUp(c, 12840, mujica.CountUpOptions{Duration: 800 * time.Millisecond}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func CountUp(c *ui.Context, value float64, opts CountUpOptions) *ui.Element` + +## 参数 + +`CountUpOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Duration | time.Duration | 0(即 120 ms) | 过渡时长;为负 panic。 | +| Decimals | int | 0 | 小数位;为负 panic。 | +| Format | func(float64) string | nil | 自定义显示,优先于 Decimals。 | + +value 须为有限值。首次显示直接为目标值。 + +## 状态 + +- 缓出过渡,过渡中途再变化时从当前显示值继续。 +- 等宽数字防止抖动;朗读最终值。 +- 减少动画:直接显示新值。过渡结束或屏外隐藏后停止重绘;恢复动画设置时保持已显示的最终值。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 无。 diff --git a/docs/components/294-typewriter.md b/docs/components/294-typewriter.md new file mode 100644 index 0000000..d2e4596 --- /dev/null +++ b/docs/components/294-typewriter.md @@ -0,0 +1,49 @@ +# 294 Typewriter 打字机 + +## 用途 + +逐字显示文本,可调速度、可跳过、完成时通知;文本增长时从当前位置继续。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Typewriter(c, "The court assembles at dusk.", mujica.TypewriterOptions{Speed: 30, Skippable: true}) + if r.Changed() { + // show the next message + } +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Typewriter(c *ui.Context, text string, opts TypewriterOptions) ChoiceResult` + +## 参数 + +`TypewriterOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Speed | float64 | 0(即每秒 40 字) | 速度;须为有限值,并产生 1 ns 到 time.Duration 上限之间的正间隔。非法值 panic。 | +| Skippable | bool | false | 显示“跳过”按钮。 | + +返回 `ChoiceResult`,含 `Element`。 + +## 状态 + +- 按定时请求帧,不连续重绘。 +- 整段文本作为名称朗读,不逐字朗读。 +- 减少动画:直接显示全文。 + +## 事件 + +- `Changed()`:全文显示完的那一帧(含跳过)。 + +## 键盘操作 + +- “跳过”按钮用 Tab 到达,Enter 或 Space 跳过。 + +## 限制 + +- 文本被替换为不以旧文本开头的内容时从头开始。 diff --git a/docs/components/295-shimmer-text.md b/docs/components/295-shimmer-text.md new file mode 100644 index 0000000..a9cefab --- /dev/null +++ b/docs/components/295-shimmer-text.md @@ -0,0 +1,46 @@ +# 295 ShimmerText 文字扫光 + +## 用途 + +处理中的状态文字(如“思考中”)上有光扫过。 + +## 最小示例 + +```go +thinking := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ShimmerText(c, "Thinking…", mujica.ShimmerTextOptions{Active: thinking}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element` + +## 参数 + +`ShimmerTextOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Active | bool | false | 扫光;否则为静态 TextMuted 文字。 | + +text 为空时 panic。 + +## 状态 + +- 逐字由 TextMuted 渐亮到 Text,再回落;只在绘制时动画。 +- 以状态角色朗读文字。 +- 减少动画或未激活:静态文字。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 扫光时文字保持单行。 diff --git a/docs/components/296-stagger.md b/docs/components/296-stagger.md new file mode 100644 index 0000000..ac1db7d --- /dev/null +++ b/docs/components/296-stagger.md @@ -0,0 +1,50 @@ +# 296 Stagger 依次进场 + +## 用途 + +子项按间隔依次进场,只在首次出现时执行一次。 + +## 最小示例 + +```go +guests := []string{"Sakiko", "Uika", "Mutsumi"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Stagger(c, len(guests), mujica.StaggerOptions{}, func(i int) { + ui.Text(c, guests[i]) + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.Element` + +## 参数 + +`StaggerOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Interval | time.Duration | 0(即 60 ms) | 相邻子项间隔。 | +| Duration | time.Duration | 0(即 PopupDuration) | 每项进场时长。 | +| Horizontal | bool | false | 横向排列。 | + +n、Interval、Duration 为负时 panic。基于 MyGo `Transition` 的 Enter。 + +## 状态 + +- 每项从 8 DIP 外淡入。 +- 序列结束后新增的项照常进场,不再等待。 +- 减少动画:一次全部显示。按定时请求帧。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 尚未出现的项不占位,容器随之增长。 diff --git a/docs/components/297-reorder-transition.md b/docs/components/297-reorder-transition.md new file mode 100644 index 0000000..7ddc9c5 --- /dev/null +++ b/docs/components/297-reorder-transition.md @@ -0,0 +1,47 @@ +# 297 ReorderTransition 重排过渡 + +## 用途 + +按稳定键排列子项,顺序变化时子项平滑移动到新位置;新增淡入,移除淡出。 + +## 最小示例 + +```go +order := []string{"Sakiko", "Uika", "Mutsumi"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ReorderTransition(c, order, mujica.ReorderTransitionOptions{Gap: 4}, func(name string) { + ui.Text(c, name) + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element` + +## 参数 + +`ReorderTransitionOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Duration | time.Duration | 0(即 120 ms) | 移动时长;为负 panic。 | +| Gap | float32 | 0 | 子项间距。 | + +键重复时 panic。基于 MyGo `Transition` 的 Position、Enter 与 Exit。 + +## 状态 + +- 减少动画:直接到位。动画结束后停止重绘。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 焦点随键跟随子项移动。 + +## 限制 + +- 只做纵向列;虚拟化列表(List)的行不支持此过渡(MyGo 文档 transitions.md)。 diff --git a/docs/components/298-layout-transition.md b/docs/components/298-layout-transition.md new file mode 100644 index 0000000..cb3cffb --- /dev/null +++ b/docs/components/298-layout-transition.md @@ -0,0 +1,52 @@ +# 298 LayoutTransition 布局过渡 + +## 用途 + +内容的尺寸与位置变化时从旧布局过渡到新布局(FLIP)。 + +## 最小示例 + +```go +open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LayoutTransition(c, mujica.LayoutTransitionOptions{Clip: true}, func() { + ui.Text(c, "Program") + if open { + ui.Text(c, "Overture · Masquerade · Finale") + } + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func()) *ui.Element` + +## 参数 + +`LayoutTransitionOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Duration | time.Duration | 0(即 120 ms) | 时长;为负 panic。 | +| Row | bool | false | 横向排列内容。 | +| Clip | bool | false | 尺寸变化时裁剪溢出内容。 | + +基于 MyGo `Transition` 的 Position 与 Size。 + +## 状态 + +- 尺寸过渡中内容按每一帧的尺寸重新布局。 +- 窗口缩放引起的变化不动画;减少动画时直接到位。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 被同一变化推动的兄弟元素需要各自的 LayoutTransition 才会同步移动。 diff --git a/docs/components/299-presence.md b/docs/components/299-presence.md new file mode 100644 index 0000000..82b0c42 --- /dev/null +++ b/docs/components/299-presence.md @@ -0,0 +1,48 @@ +# 299 Presence 进出场 + +## 用途 + +内容随 show 进出场:淡入淡出、滑动或折叠,退出完成后移除。 + +## 最小示例 + +```go +show := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Presence(c, show, mujica.PresenceOptions{Kind: mujica.PresenceSlide}, func() { + ui.Text(c, "Saved") + }) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element` + +## 参数 + +`PresenceOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Kind | PresenceKind | PresenceFade | PresenceFade、PresenceSlide、PresenceCollapse;未知值 panic。 | +| Duration | time.Duration | 0(即 PopupDuration) | 时长;为负 panic。 | + +基于 MyGo `Transition` 的 Enter 与 Exit:退出时保留上一帧的副本直到动画结束,不占布局、不接收指针与焦点。 + +## 状态 + +- 减少动画:直接出现与消失。动画结束后停止重绘。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 退出中的内容不接收焦点。 + +## 限制 + +- 不提供缩放:MyGo `ui.Motion` 只有位移、透明度与折叠(ui/transition.go:40)。 +- 父元素在上一帧不存在时,内容随父元素一起出现而不进场。 diff --git a/docs/components/300-blink-highlight.md b/docs/components/300-blink-highlight.md new file mode 100644 index 0000000..533e567 --- /dev/null +++ b/docs/components/300-blink-highlight.md @@ -0,0 +1,47 @@ +# 300 BlinkHighlight 变化高亮 + +## 用途 + +数值变化时单次高亮:上升 Success、下降 Danger,并留下方向箭头。 + +## 最小示例 + +```go +price := 101.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlinkHighlight(c, price, mujica.BlinkHighlightOptions{}) +} +ui.Render(view, 400, 300, 1) +``` + +签名:`func BlinkHighlight(c *ui.Context, value float64, opts BlinkHighlightOptions) *ui.Element` + +## 参数 + +`BlinkHighlightOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| Format | func(float64) string | 两位小数 | 显示格式。 | +| Duration | time.Duration | 0(即 120 ms) | 高亮淡出时长;为负 panic。 | + +value 须为有限值;首次显示不高亮。 + +## 状态 + +- 背景从 22% 透明度淡出一次,结束后停止重绘。 +- 方向箭头与朗读的“上升/下降”在变化后一直保留,不只靠颜色。 +- 减少动画:不闪烁,只显示静态箭头。 + +## 事件 + +- 无。 + +## 键盘操作 + +- 无。 + +## 限制 + +- 开启减少动画会结束当前高亮;恢复动画时保持静态方向标记。 diff --git a/dots_loader.go b/dots_loader.go index b1cbb8a..653c4f4 100644 --- a/dots_loader.go +++ b/dots_loader.go @@ -12,18 +12,18 @@ import ( 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 is read out; 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. +// loaderDraw paints phase t; still enables 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 { +// loaderMark builds a loader that repaints only while painted. +func loaderMark(c *ui.Context, opts LoaderOptions, key string, 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") } @@ -34,7 +34,7 @@ func loaderMark(c *ui.Context, opts LoaderOptions, aspect float32, period int64, } label := opts.Label if label == "" { - label = msg(c, "loading") + label = msg(c, key+".loading") } col := opts.Color if col.A == 0 { @@ -59,12 +59,12 @@ func loaderMark(c *ui.Context, opts LoaderOptions, aspect float32, period int64, return row } -// loaderWave is a smooth 0..1 rise and fall over phase t. +// loaderWave rises and falls between 0 and 1. 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) { + return loaderMark(c, opts, "dots_loader", 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) diff --git a/dots_loader_test.go b/dots_loader_test.go new file mode 100644 index 0000000..5d6c7c1 --- /dev/null +++ b/dots_loader_test.go @@ -0,0 +1,7 @@ +package mujica + +import "testing" + +func TestDotsLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "dots_loader", DotsLoader) } + +func TestDotsLoaderBehavior(t *testing.T) { testLoaderBehavior(t, DotsLoader) } diff --git a/eval_result_table.go b/eval_result_table.go index 915e0c7..8ed4d3d 100644 --- a/eval_result_table.go +++ b/eval_result_table.go @@ -26,18 +26,27 @@ type EvalResultTableOptions struct { Decimals int Status DataStatus Error string + // Version changes when cases are edited in place. + Version int } // EvalResultTableResult is a built EvalResultTable and its events. type EvalResultTableResult struct { - Table DataTableView - diff string + Element *ui.Element + Table DataTableView + diff string } -// Diff returns the case whose differences the user asked to see this frame. +// Diff returns the case to show differences for. 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. +type evalSummary struct { + src *EvalCase + n, version, passed int + built bool +} + +// EvalResultTable lists scored cases with a pass summary. func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, opts EvalResultTableOptions) EvalResultTableResult { if opts.Decimals < 0 { panic("mujica: EvalResultTable Decimals is negative") @@ -46,18 +55,30 @@ func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, 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 + col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2) + res := EvalResultTableResult{Element: col} + summary := ui.Local(col, "summary", func() evalSummary { return evalSummary{} }) + var first *EvalCase + if len(cases) > 0 { + first = &cases[0] + } + if !summary.built || summary.src != first || summary.n != len(cases) || summary.version != opts.Version { + passed := 0 + seen := make(map[string]bool, len(cases)) + for _, ec := range cases { + if ec.ID == "" || seen[ec.ID] || math.IsNaN(ec.Score) || math.IsInf(ec.Score, 0) { + panic("mujica: EvalCase needs a unique ID and finite Score") + } + seen[ec.ID] = true + if ec.Passed { + passed++ + } + } + *summary = evalSummary{src: first, n: len(cases), version: opts.Version, passed: passed, built: true} + } + passed := summary.passed verdict := func(ec EvalCase) string { if ec.Passed { return msg(c, "eval_result_table.pass") @@ -89,7 +110,6 @@ func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, } }}, } - 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() { @@ -100,7 +120,7 @@ func EvalResultTable(c *ui.Context, s *DataTableState[string], cases []EvalCase, }) } 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}) + Empty: msg(c, "eval_result_table.empty"), Status: opts.Status, Error: opts.Error, Version: opts.Version}) res.Table.Element.Grow(1) }) res.Table.Element = col diff --git a/eval_result_table_test.go b/eval_result_table_test.go index 80051fd..3c30132 100644 --- a/eval_result_table_test.go +++ b/eval_result_table_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strconv" "testing" "github.com/egoist/mygo/ui" @@ -72,3 +73,69 @@ func TestEvalResultTableSnapshot(t *testing.T) { }) }) } + +func TestEvalResultTableDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("duplicate case IDs accepted") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + var state DataTableState[string] + EvalResultTable(c, &state, []EvalCase{{ID: "a"}, {ID: "a"}}, EvalResultTableOptions{}) + }, 500, 300, 1) +} + +func TestEvalResultTableDisabledKeyboardDiff(t *testing.T) { + var state DataTableState[string] + disabled := false + diffs := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r := EvalResultTable(c, &state, evalSample(), EvalResultTableOptions{}) + r.Table.Element.Height(300) + if _, ok := r.Diff(); ok { + diffs++ + } + }) + }, 700, 400) + tabTo(t, tt, "View differences for Greets by title") + for i := 0; i < 8 && diffs == 0; i++ { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + } + if diffs != 1 { + t.Fatalf("keyboard diffs %d", diffs) + } + disabled = true + tt.Key(0, ui.KeyEnter) + _ = tt.Click("View differences for Greets by title") + if diffs != 1 { + t.Fatalf("disabled diffs %d", diffs) + } +} + +func TestEvalResultTableLargeDataAndVersion(t *testing.T) { + cases := make([]EvalCase, 100000) + for i := range cases { + cases[i] = EvalCase{ID: strconv.Itoa(i), Name: "Case " + strconv.Itoa(i), Score: 1, Passed: true} + } + var s DataTableState[string] + version := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + EvalResultTable(c, &s, cases, EvalResultTableOptions{Version: version}).Element.Height(300) + }, 700, 400) + a, b := s.rows.Visible() + if b-a > 30 || !tt.HasText("100000 of 100000 passed") { + t.Fatalf("rows %d summary missing", b-a) + } + cases[0].Passed = false + version++ + tt.Frame() + if !tt.HasText("99999 of 100000 passed") { + t.Fatal("stale summary") + } +} diff --git a/example_motion_test.go b/example_motion_test.go new file mode 100644 index 0000000..e0e8184 --- /dev/null +++ b/example_motion_test.go @@ -0,0 +1,221 @@ +package mujica_test + +import ( + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleInterruptButton() { + running, stopping := true, false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.InterruptButton(c, running, mujica.InterruptButtonOptions{Confirm: true, Interrupting: stopping}) + if r.Changed() { + stopping = true // cancel the run, then clear running and stopping + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTokenUsageChart() { + usage := []mujica.TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Write", Input: 2100, Output: 2900}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TokenUsageChart(c, usage, mujica.TokenUsageChartOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCostBreakdown() { + var state mujica.CostBreakdownState + items := []mujica.CostItem{ + {Model: "opus", Tool: "search", InputTokens: 1000, OutputTokens: 200, Cost: 0.30}, + {Model: "haiku", Tool: "search", InputTokens: 4000, OutputTokens: 500, Cost: 0.05}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostBreakdown(c, &state, items, mujica.CostBreakdownOptions{GroupBy: mujica.CostByModel}).Element.Height(260) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTraceViewer() { + var state mujica.TraceViewerState + spans := []mujica.TraceSpan{ + {ID: "run", Name: "agent.run", End: 900 * time.Millisecond}, + {ID: "plan", Parent: "run", Name: "llm.plan", Start: 10 * time.Millisecond, End: 210 * time.Millisecond, + Attrs: []mujica.TraceAttr{{Key: "model", Value: "opus"}}}, + } + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TraceViewer(c, &state, spans, mujica.TraceViewerOptions{}).Element.Height(260) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleEvalResultTable() { + var state mujica.DataTableState[string] + cases := []mujica.EvalCase{{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, {ID: "c2", Name: "Refuses forgery", Score: 0.41}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EvalResultTable(c, &state, cases, mujica.EvalResultTableOptions{}) + r.Table.Element.Height(260) + if id, ok := r.Diff(); ok { + _ = id // open expected and actual output side by side + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleToolRegistryPanel() { + var state mujica.ListState[string] + tools := []mujica.AgentTool{{Name: "web_search", Description: "Search the web", Enabled: true, + Params: []mujica.ToolParam{{Name: "query", Type: "string", Required: true}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolRegistryPanel(c, &state, tools, mujica.ToolRegistryPanelOptions{}) + r.Element.Height(260) + if name, on, ok := r.Toggled(); ok && name == tools[0].Name { + tools[0].Enabled = on + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDotsLoader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DotsLoader(c, mujica.LoaderOptions{Label: "Thinking", ShowLabel: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBarsLoader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarsLoader(c, mujica.LoaderOptions{Size: 20, Label: "Indexing"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePulseLoader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PulseLoader(c, mujica.LoaderOptions{Label: "Listening", ShowLabel: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleOrbitLoader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OrbitLoader(c, mujica.LoaderOptions{Size: 24, Label: "Connecting"}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleWaveLoader() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaveLoader(c, mujica.LoaderOptions{Label: "Speaking", ShowLabel: true}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleShimmer() { + loading := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Shimmer(c, loading, mujica.ShimmerOptions{Height: 48}, func() { + ui.Text(c, "Report ready") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCountUp() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CountUp(c, 12840, mujica.CountUpOptions{Duration: 800 * time.Millisecond}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTypewriter() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Typewriter(c, "The court assembles at dusk.", mujica.TypewriterOptions{Speed: 30, Skippable: true}) + if r.Changed() { + // show the next message + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleShimmerText() { + thinking := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ShimmerText(c, "Thinking…", mujica.ShimmerTextOptions{Active: thinking}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleStagger() { + guests := []string{"Sakiko", "Uika", "Mutsumi"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Stagger(c, len(guests), mujica.StaggerOptions{}, func(i int) { + ui.Text(c, guests[i]) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleReorderTransition() { + order := []string{"Sakiko", "Uika", "Mutsumi"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ReorderTransition(c, order, mujica.ReorderTransitionOptions{Gap: 4}, func(name string) { + ui.Text(c, name) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleLayoutTransition() { + open := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LayoutTransition(c, mujica.LayoutTransitionOptions{Clip: true}, func() { + ui.Text(c, "Program") + if open { + ui.Text(c, "Overture · Masquerade · Finale") + } + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePresence() { + show := true + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Presence(c, show, mujica.PresenceOptions{Kind: mujica.PresenceSlide}, func() { + ui.Text(c, "Saved") + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleBlinkHighlight() { + price := 101.5 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlinkHighlight(c, price, mujica.BlinkHighlightOptions{}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_motion.go b/examples/gallery/pages_motion.go index 64cf06f..7a0764f 100644 --- a/examples/gallery/pages_motion.go +++ b/examples/gallery/pages_motion.go @@ -1,3 +1,258 @@ package main -var motionPages = []page{} +import mujica "mujicaui" + +var motionPages = []page{ + { + ID: "281", Name: "InterruptButton", ZhName: "中断按钮 停止智能体", Category: "Agent Ops & Motion", + Demo: motionInterruptDemo, + Code: `running, stopping := true, false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.InterruptButton(c, running, mujica.InterruptButtonOptions{Confirm: true, Interrupting: stopping}) + if r.Changed() { + stopping = true // cancel the run, then clear running and stopping + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab focuses Stop; Enter or Space interrupts. With Confirm, the popup focuses Keep running; Tab reaches Stop; Escape cancels.", + }, + { + ID: "282", Name: "TokenUsageChart", ZhName: "Token 用量图 输入输出", Category: "Agent Ops & Motion", + Demo: motionTokenUsageDemo, + Code: `usage := []mujica.TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Write", Input: 2100, Output: 2900}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TokenUsageChart(c, usage, mujica.TokenUsageChartOptions{}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Tab focuses the chart; Left/Right move between bars or points and the readout is announced.", + }, + { + ID: "283", Name: "CostBreakdown", ZhName: "费用分解 成本 合计", Category: "Agent Ops & Motion", + Demo: motionCostDemo, + Code: `var state mujica.CostBreakdownState +items := []mujica.CostItem{ + {Model: "opus", Tool: "search", InputTokens: 1000, OutputTokens: 200, Cost: 0.30}, + {Model: "haiku", Tool: "search", InputTokens: 4000, OutputTokens: 500, Cost: 0.05}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CostBreakdown(c, &state, items, mujica.CostBreakdownOptions{GroupBy: mujica.CostByModel}).Element.Height(260) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a group; column headers sort with Enter; the column menu takes Tab.", + }, + { + ID: "284", Name: "TraceViewer", ZhName: "调用链 追踪 跨度 时间轴", Category: "Agent Ops & Motion", + Demo: motionTraceDemo, + Code: `var state mujica.TraceViewerState +spans := []mujica.TraceSpan{ + {ID: "run", Name: "agent.run", End: 900 * time.Millisecond}, + {ID: "plan", Parent: "run", Name: "llm.plan", Start: 10 * time.Millisecond, End: 210 * time.Millisecond, + Attrs: []mujica.TraceAttr{{Key: "model", Value: "opus"}}}, +} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TraceViewer(c, &state, spans, mujica.TraceViewerOptions{}).Element.Height(260) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a span, Right opens and Left closes it; typing jumps by name.", + }, + { + ID: "285", Name: "EvalResultTable", ZhName: "评测结果 得分 通过失败", Category: "Agent Ops & Motion", + Demo: motionEvalDemo, + Code: `var state mujica.DataTableState[string] +cases := []mujica.EvalCase{{ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, {ID: "c2", Name: "Refuses forgery", Score: 0.41}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.EvalResultTable(c, &state, cases, mujica.EvalResultTableOptions{}) + r.Table.Element.Height(260) + if id, ok := r.Diff(); ok { + _ = id // open expected and actual output side by side + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a case; the diff button takes Tab, Enter or Space.", + }, + { + ID: "286", Name: "ToolRegistryPanel", ZhName: "工具注册 工具列表 启停", Category: "Agent Ops & Motion", + Demo: motionToolsDemo, + Code: `var state mujica.ListState[string] +tools := []mujica.AgentTool{{Name: "web_search", Description: "Search the web", Enabled: true, + Params: []mujica.ToolParam{{Name: "query", Type: "string", Required: true}}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ToolRegistryPanel(c, &state, tools, mujica.ToolRegistryPanelOptions{}) + r.Element.Height(260) + if name, on, ok := r.Toggled(); ok && name == tools[0].Name { + tools[0].Enabled = on + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Up/Down choose a tool; its switch takes Tab and Space.", + }, + { + ID: "287", Name: "DotsLoader", ZhName: "三点加载 加载中", Category: "Agent Ops & Motion", + Demo: motionLoaderDemo("287", mujica.DotsLoader), + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DotsLoader(c, mujica.LoaderOptions{Label: "Thinking", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress.", + }, + { + ID: "288", Name: "BarsLoader", ZhName: "条形加载", Category: "Agent Ops & Motion", + Demo: motionLoaderDemo("288", mujica.BarsLoader), + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BarsLoader(c, mujica.LoaderOptions{Size: 20, Label: "Indexing"}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress.", + }, + { + ID: "289", Name: "PulseLoader", ZhName: "呼吸加载 脉冲", Category: "Agent Ops & Motion", + Demo: motionLoaderDemo("289", mujica.PulseLoader), + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.PulseLoader(c, mujica.LoaderOptions{Label: "Listening", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress.", + }, + { + ID: "290", Name: "OrbitLoader", ZhName: "环绕加载 菱形", Category: "Agent Ops & Motion", + Demo: motionLoaderDemo("290", mujica.OrbitLoader), + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OrbitLoader(c, mujica.LoaderOptions{Size: 24, Label: "Connecting"}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress.", + }, + { + ID: "291", Name: "WaveLoader", ZhName: "波形加载", Category: "Agent Ops & Motion", + Demo: motionLoaderDemo("291", mujica.WaveLoader), + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.WaveLoader(c, mujica.LoaderOptions{Label: "Speaking", ShowLabel: true}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress.", + }, + { + ID: "292", Name: "Shimmer", ZhName: "占位扫光 骨架 加载", Category: "Agent Ops & Motion", + Demo: motionShimmerDemo, + Code: `loading := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Shimmer(c, loading, mujica.ShimmerOptions{Height: 48}, func() { + ui.Text(c, "Report ready") + }) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as progress while loading.", + }, + { + ID: "293", Name: "CountUp", ZhName: "数字过渡 计数", Category: "Agent Ops & Motion", + Demo: motionCountUpDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CountUp(c, 12840, mujica.CountUpOptions{Duration: 800 * time.Millisecond}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; the final value is read out.", + }, + { + ID: "294", Name: "Typewriter", ZhName: "打字机 逐字显示", Category: "Agent Ops & Motion", + Demo: motionTypewriterDemo, + Code: `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.Typewriter(c, "The court assembles at dusk.", mujica.TypewriterOptions{Speed: 30, Skippable: true}) + if r.Changed() { + // show the next message + } +} +ui.Render(view, 400, 300, 1)`, + Keys: "Skip takes Tab, Enter or Space.", + }, + { + ID: "295", Name: "ShimmerText", ZhName: "文字扫光 思考中", Category: "Agent Ops & Motion", + Demo: motionShimmerTextDemo, + Code: `thinking := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ShimmerText(c, "Thinking…", mujica.ShimmerTextOptions{Active: thinking}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; read as a status.", + }, + { + ID: "296", Name: "Stagger", ZhName: "依次进场 交错动画", Category: "Agent Ops & Motion", + Demo: motionStaggerDemo, + Code: `guests := []string{"Sakiko", "Uika", "Mutsumi"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Stagger(c, len(guests), mujica.StaggerOptions{}, func(i int) { + ui.Text(c, guests[i]) + }) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable.", + }, + { + ID: "297", Name: "ReorderTransition", ZhName: "重排过渡 排序动画", Category: "Agent Ops & Motion", + Demo: motionReorderDemo, + Code: `order := []string{"Sakiko", "Uika", "Mutsumi"} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ReorderTransition(c, order, mujica.ReorderTransitionOptions{Gap: 4}, func(name string) { + ui.Text(c, name) + }) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Focus inside an item follows it as it moves.", + }, + { + ID: "298", Name: "LayoutTransition", ZhName: "布局过渡 FLIP", Category: "Agent Ops & Motion", + Demo: motionLayoutDemo, + Code: `open := false +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LayoutTransition(c, mujica.LayoutTransitionOptions{Clip: true}, func() { + ui.Text(c, "Program") + if open { + ui.Text(c, "Overture · Masquerade · Finale") + } + }) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable.", + }, + { + ID: "299", Name: "Presence", ZhName: "进出场 淡入淡出", Category: "Agent Ops & Motion", + Demo: motionPresenceDemo, + Code: `show := true +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.Presence(c, show, mujica.PresenceOptions{Kind: mujica.PresenceSlide}, func() { + ui.Text(c, "Saved") + }) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Leaving content takes no focus.", + }, + { + ID: "300", Name: "BlinkHighlight", ZhName: "变化高亮 闪烁 涨跌", Category: "Agent Ops & Motion", + Demo: motionBlinkDemo, + Code: `price := 101.5 +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.BlinkHighlight(c, price, mujica.BlinkHighlightOptions{}) +} +ui.Render(view, 400, 300, 1)`, + Keys: "Not focusable; the value and direction are read out.", + }, +} diff --git a/examples/gallery/pages_motion_fx.go b/examples/gallery/pages_motion_fx.go new file mode 100644 index 0000000..43348ed --- /dev/null +++ b/examples/gallery/pages_motion_fx.go @@ -0,0 +1,220 @@ +package main + +import ( + "fmt" + "math/rand" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +// motionLoaderDemo shows a loader while loading. +func motionLoaderDemo(id string, loader func(c *ui.Context, o mujica.LoaderOptions) *ui.Element) func(c *ui.Context, log func(string)) { + return func(c *ui.Context, log func(string)) { + type state struct { + loading, label bool + size float64 + } + s := ui.Local(c.Root(), id, func() state { return state{loading: true, label: true, size: 20} }) + navigationDemo(c, func() { + navigationCheck(c, &s.loading, "Loading", log) + navigationCheck(c, &s.label, "Show label", log) + navigationSlider(c, &s.size, "Size", 12, 48, log) + }, func() { + ui.Row(c).Gap(24).AlignItems(ui.Center).Children(func() { + if !s.loading { + ui.Text(c, "Done.") + return + } + loader(c, mujica.LoaderOptions{Size: float32(s.size), Label: "Thinking", ShowLabel: s.label}) + loader(c, mujica.LoaderOptions{Size: float32(s.size), Label: "Indexing", Color: mujica.Tokens(c).Info}) + }) + }) + } +} + +func motionShimmerDemo(c *ui.Context, log func(string)) { + type state struct{ loading bool } + s := ui.Local(c.Root(), "292", func() state { return state{loading: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.loading, "Loading", log) + }, func() { + mujica.Shimmer(c, s.loading, mujica.ShimmerOptions{Height: 56, Label: "Loading report"}, func() { + ui.Text(c, "Quarterly report: 12 chapters, 340 pages.") + }) + mujica.Shimmer(c, s.loading, mujica.ShimmerOptions{Width: 240, Height: 20}, func() { + ui.Text(c, "Updated today").TextColor(mujica.Tokens(c).TextMuted) + }) + }) +} + +func motionCountUpDemo(c *ui.Context, log func(string)) { + type state struct{ value, duration, decimals float64 } + s := ui.Local(c.Root(), "293", func() state { return state{value: 1280, duration: 120} }) + navigationDemo(c, func() { + navigationSlider(c, &s.duration, "Duration (ms)", 120, 1200, log) + navigationSlider(c, &s.decimals, "Decimals", 0, 4, log) + if mujica.Button(c, "Add 500", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.value += 500 + log(fmt.Sprintf("Value %.0f", s.value)) + } + if mujica.Button(c, "Random", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.value = float64(rand.Intn(100000)) + log(fmt.Sprintf("Value %.0f", s.value)) + } + }, func() { + mujica.CountUp(c, s.value, mujica.CountUpOptions{Duration: time.Duration(s.duration) * time.Millisecond, Decimals: int(s.decimals)}).FontSize(mujica.FontSize(c, 32)) + mujica.CountUp(c, s.value/1000, mujica.CountUpOptions{Decimals: 2, Duration: time.Second}) + }) +} + +func motionTypewriterDemo(c *ui.Context, log func(string)) { + type state struct { + text string + speed float64 + round int + } + s := ui.Local(c.Root(), "294", func() state { return state{text: "The court assembles at dusk.", speed: 30} }) + navigationDemo(c, func() { + navigationSlider(c, &s.speed, "Speed", 5, 120, log) + if mujica.Button(c, "Append", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.text += " The music begins." + log("Appended") + } + if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.text = "A new letter arrives from the duchess." + s.round++ + log("Restarted") + } + }, func() { + ui.Column(c).Key(s.round).Children(func() { + if mujica.Typewriter(c, s.text, mujica.TypewriterOptions{Speed: s.speed, Skippable: true}).Changed() { + log("Finished") + } + }) + }) +} + +func motionShimmerTextDemo(c *ui.Context, log func(string)) { + type state struct{ active bool } + s := ui.Local(c.Root(), "295", func() state { return state{active: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.active, "Thinking", log) + }, func() { + mujica.ShimmerText(c, "Thinking about the program…", mujica.ShimmerTextOptions{Active: s.active}) + mujica.ShimmerText(c, "思考中…", mujica.ShimmerTextOptions{Active: s.active}) + }) +} + +func motionStaggerDemo(c *ui.Context, log func(string)) { + type state struct { + round int + interval float64 + horizontal bool + } + s := ui.Local(c.Root(), "296", func() state { return state{interval: 60} }) + guests := []string{"Sakiko", "Mutsumi", "Uika", "Umiri", "Nyamu"} + navigationDemo(c, func() { + navigationSlider(c, &s.interval, "Interval (ms)", 20, 300, log) + navigationCheck(c, &s.horizontal, "Horizontal", log) + if mujica.Button(c, "Replay", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.round++ + log(fmt.Sprintf("Replay %d", s.round)) + } + }, func() { + ui.Column(c).Key(s.round).Children(func() { + mujica.Stagger(c, len(guests), mujica.StaggerOptions{Interval: time.Duration(s.interval) * time.Millisecond, Horizontal: s.horizontal}, func(i int) { + ui.Text(c, guests[i]+" arrives").Padding(6, 10).Background(mujica.Tokens(c).Surface).Radius(4).Margin(0, 0, 4, 0) + }) + }) + }) +} + +func motionReorderDemo(c *ui.Context, log func(string)) { + type state struct { + order []string + gap float64 + } + s := ui.Local(c.Root(), "297", func() state { return state{order: []string{"Overture", "Masquerade", "Interlude", "Finale"}, gap: 4} }) + navigationDemo(c, func() { + navigationSlider(c, &s.gap, "Gap", 0, 24, log) + if mujica.Button(c, "Shuffle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + next := append([]string(nil), s.order...) + rand.Shuffle(len(next), func(i, j int) { next[i], next[j] = next[j], next[i] }) + s.order = next + log(fmt.Sprintf("Order %v", s.order)) + } + if mujica.Button(c, "Reverse", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + next := make([]string, len(s.order)) + for i, v := range s.order { + next[len(next)-1-i] = v + } + s.order = next + log(fmt.Sprintf("Order %v", s.order)) + } + }, func() { + ui.Column(c).Width(280).Children(func() { + mujica.ReorderTransition(c, s.order, mujica.ReorderTransitionOptions{Gap: float32(s.gap)}, func(name string) { + ui.Text(c, name).Padding(8, 12).Background(mujica.Tokens(c).Surface).Radius(4) + }) + }) + }) +} + +func motionLayoutDemo(c *ui.Context, log func(string)) { + type state struct{ open, clip bool } + s := ui.Local(c.Root(), "298", func() state { return state{clip: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.open, "Expanded", log) + navigationCheck(c, &s.clip, "Clip", log) + }, func() { + ui.Column(c).AlignItems(ui.Start).Children(func() { + mujica.LayoutTransition(c, mujica.LayoutTransitionOptions{Clip: s.clip}, func() { + ui.Column(c).Padding(12).Gap(6).Background(mujica.Tokens(c).Surface).Radius(4).Children(func() { + ui.Text(c, "Program").FontWeight(600) + if s.open { + for _, p := range []string{"Overture", "Masquerade", "Interlude", "Finale"} { + ui.Text(c, p) + } + } + }) + }) + }) + }) +} + +func motionPresenceDemo(c *ui.Context, log func(string)) { + type state struct { + show bool + kind int + } + s := ui.Local(c.Root(), "299", func() state { return state{show: true} }) + navigationDemo(c, func() { + navigationCheck(c, &s.show, "Show", log) + if mujica.SegmentedControl(c, &s.kind, []mujica.Segment{{Label: "Fade"}, {Label: "Slide"}, {Label: "Collapse"}}, mujica.SegmentedControlOptions{Label: "Kind"}).Changed() { + log(fmt.Sprintf("Kind %d", s.kind)) + } + }, func() { + ui.Column(c).Width(320).Children(func() { + mujica.Presence(c, s.show, mujica.PresenceOptions{Kind: mujica.PresenceKind(s.kind)}, func() { + ui.Text(c, "Your changes were saved.").Padding(10, 12).Background(mujica.Tokens(c).Surface).Radius(4) + }) + ui.Text(c, "Content below moves with it.").TextColor(mujica.Tokens(c).TextMuted).Padding(8, 0) + }) + }) +} + +func motionBlinkDemo(c *ui.Context, log func(string)) { + type state struct{ price float64 } + s := ui.Local(c.Root(), "300", func() state { return state{price: 101.5} }) + navigationDemo(c, func() { + if mujica.Button(c, "Tick", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.price += float64(rand.Intn(200)-100) / 50 + log(fmt.Sprintf("Price %.2f", s.price)) + } + }, func() { + mujica.BlinkHighlight(c, s.price, mujica.BlinkHighlightOptions{}) + }) +} diff --git a/examples/gallery/pages_motion_ops.go b/examples/gallery/pages_motion_ops.go new file mode 100644 index 0000000..35ae608 --- /dev/null +++ b/examples/gallery/pages_motion_ops.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func motionInterruptDemo(c *ui.Context, log func(string)) { + type state struct { + running, stopping, confirm bool + until time.Time + } + s := ui.Local(c.Root(), "281", func() state { return state{running: true, confirm: true} }) + if s.stopping && c.Now().After(s.until) { + s.running, s.stopping = false, false + log("Run stopped") + } + if s.stopping { + c.After(s.until.Sub(c.Now())) + } + navigationDemo(c, func() { + navigationCheck(c, &s.confirm, "Confirm", log) + if mujica.Button(c, "Start run", mujica.ButtonOptions{Variant: mujica.Secondary, Disabled: s.running}).Clicked() { + s.running = true + log("Run started") + } + }, func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + if s.running && !s.stopping { + mujica.DotsLoader(c, mujica.LoaderOptions{Label: "Agent running", ShowLabel: true}) + } + if mujica.InterruptButton(c, s.running, mujica.InterruptButtonOptions{Confirm: s.confirm, Interrupting: s.stopping}).Changed() { + log("Interrupt requested") + s.stopping, s.until = true, c.Now().Add(1500*time.Millisecond) + } + }) + }) +} + +func motionTokenUsageDemo(c *ui.Context, log func(string)) { + type state struct{ line, loading, empty, failed bool } + s := ui.Local(c.Root(), "282", func() state { return state{} }) + usage := *ui.Local(c.Root(), "282.data", func() []mujica.TokenUsage { + return []mujica.TokenUsage{{Label: "Plan", Input: 1200, Output: 300}, {Label: "Search", Input: 4800, Output: 650}, + {Label: "Read", Input: 6100, Output: 420}, {Label: "Write", Input: 2100, Output: 2900}, {Label: "Review", Input: 3300, Output: 800}} + }) + navigationDemo(c, func() { + navigationCheck(c, &s.line, "Lines", log) + navigationCheck(c, &s.loading, "Loading", log) + navigationCheck(c, &s.empty, "Empty", log) + navigationCheck(c, &s.failed, "Error", log) + }, func() { + st := mujica.DataReady + if s.loading { + st = mujica.DataLoading + } + if s.failed { + st = mujica.DataFailed + } + if s.empty { + usage = nil + } + r := mujica.TokenUsageChart(c, usage, mujica.TokenUsageChartOptions{Line: s.line, Frame: mujica.ChartFrame{Status: st, Error: "Usage unavailable"}}) + if r.Changed() && r.Point >= 0 { + log(fmt.Sprintf("Readout: mark %d", r.Point)) + } + if name, ok := r.Toggled(); ok { + log("Toggled " + name) + } + }) +} + +func motionCostDemo(c *ui.Context, log func(string)) { + type state struct { + table mujica.CostBreakdownState + by int + } + s := ui.Local(c.Root(), "283", func() state { return state{} }) + items := *ui.Local(c.Root(), "283.data", func() []mujica.CostItem { + return []mujica.CostItem{ + {Model: "opus", Tool: "search", Step: "plan", InputTokens: 1000, OutputTokens: 200, Cost: 0.30}, + {Model: "haiku", Tool: "search", Step: "gather", InputTokens: 4000, OutputTokens: 500, Cost: 0.05}, + {Model: "opus", Tool: "write", Step: "write", InputTokens: 2000, OutputTokens: 3000, Cost: 1.15}, + {Model: "sonnet", Tool: "read", Step: "gather", InputTokens: 6000, OutputTokens: 400, Cost: 0.24}, + } + }) + navigationDemo(c, func() { + if mujica.SegmentedControl(c, &s.by, []mujica.Segment{{Label: "Model"}, {Label: "Tool"}, {Label: "Step"}}, mujica.SegmentedControlOptions{Label: "Group by"}).Changed() { + log(fmt.Sprintf("Group by %d", s.by)) + } + }, func() { + v := mujica.CostBreakdown(c, &s.table, items, mujica.CostBreakdownOptions{GroupBy: mujica.CostGroup(s.by)}) + v.Element.Height(320) + if v.SortChanged() { + log("Sorted by " + s.table.Table.Sort.Column) + } + if key, ok := s.table.Table.Selected(); ok && v.Changed() { + log("Selected " + key) + } + }) +} + +func motionTraceDemo(c *ui.Context, log func(string)) { + type state struct { + viewer mujica.TraceViewerState + empty, loading, failed, disabled bool + } + s := ui.Local(c.Root(), "284", func() state { return state{} }) + spans := *ui.Local(c.Root(), "284.data", func() []mujica.TraceSpan { + return []mujica.TraceSpan{ + {ID: "run", Name: "agent.run", End: 900 * time.Millisecond, Attrs: []mujica.TraceAttr{{Key: "model", Value: "opus"}, {Key: "session", Value: "s-42"}}}, + {ID: "plan", Parent: "run", Name: "llm.plan", Start: 10 * time.Millisecond, End: 210 * time.Millisecond, Attrs: []mujica.TraceAttr{{Key: "tokens", Value: "1200"}}}, + {ID: "search", Parent: "run", Name: "tool.search", Start: 220 * time.Millisecond, End: 520 * time.Millisecond, Failed: true, Attrs: []mujica.TraceAttr{{Key: "error", Value: "timeout"}}}, + {ID: "fetch", Parent: "search", Name: "http.get", Start: 230 * time.Millisecond, End: 500 * time.Millisecond}, + {ID: "write", Parent: "run", Name: "llm.write", Start: 530 * time.Millisecond, End: 880 * time.Millisecond}, + } + }) + navigationDemo(c, func() { + navigationCheck(c, &s.empty, "Empty", log) + navigationCheck(c, &s.loading, "Loading", log) + navigationCheck(c, &s.failed, "Error", log) + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + status := mujica.DataReady + if s.empty || s.loading || s.failed { + spans = nil + } + if s.loading { + status = mujica.DataLoading + } + if s.failed { + status = mujica.DataFailed + } + ui.Column(c).Disabled(s.disabled).Children(func() { + v := mujica.TraceViewer(c, &s.viewer, spans, mujica.TraceViewerOptions{Status: status, Error: "Trace unavailable"}) + v.Element.Height(440) + if id, ok := s.viewer.Tree.Selected(); ok && v.Changed() { + log("Selected " + id) + } + if v.SortChanged() { + log("Sorted by " + s.viewer.Tree.Sort.Column) + } + if v.Retried() { + log("Retry trace requested") + } + }) + }) +} + +func motionEvalDemo(c *ui.Context, log func(string)) { + type state struct { + table mujica.DataTableState[string] + empty, loading, failed, disabled bool + } + s := ui.Local(c.Root(), "285", func() state { return state{} }) + cases := *ui.Local(c.Root(), "285.data", func() []mujica.EvalCase { + return []mujica.EvalCase{ + {ID: "c1", Name: "Greets by title", Score: 0.98, Passed: true}, + {ID: "c2", Name: "Refuses forgery", Score: 0.41}, + {ID: "c3", Name: "Summarizes letter", Score: 0.87, Passed: true}, + {ID: "c4", Name: "Keeps the schedule", Score: 0.62}, + } + }) + navigationDemo(c, func() { + navigationCheck(c, &s.empty, "Empty", log) + navigationCheck(c, &s.loading, "Loading", log) + navigationCheck(c, &s.failed, "Error", log) + navigationCheck(c, &s.disabled, "Disabled", log) + }, func() { + status := mujica.DataReady + if s.empty || s.loading || s.failed { + cases = nil + } + if s.loading { + status = mujica.DataLoading + } + if s.failed { + status = mujica.DataFailed + } + ui.Column(c).Disabled(s.disabled).Children(func() { + r := mujica.EvalResultTable(c, &s.table, cases, mujica.EvalResultTableOptions{Status: status, Error: "Evaluation unavailable"}) + r.Element.Height(320) + if id, ok := r.Diff(); ok { + log("Diff " + id) + } + if r.Table.Changed() { + id, _ := s.table.Selected() + log("Selected " + id) + } + if r.Table.SortChanged() { + log("Sorted by " + s.table.Sort.Column) + } + if r.Table.Retried() { + log("Retry evaluation requested") + } + }) + }) +} + +func motionToolsDemo(c *ui.Context, log func(string)) { + type state struct { + list mujica.ListState[string] + tools []mujica.AgentTool + disabled, empty bool + } + s := ui.Local(c.Root(), "286", func() state { + return state{tools: []mujica.AgentTool{ + {Name: "web_search", Description: "Search the web", Enabled: true, Params: []mujica.ToolParam{ + {Name: "query", Type: "string", Required: true, Description: "What to look for"}, {Name: "limit", Type: "integer"}}}, + {Name: "read_file", Description: "Read a local file", Params: []mujica.ToolParam{{Name: "path", Type: "string", Required: true}}}, + {Name: "run_shell", Description: "Run a shell command", Params: []mujica.ToolParam{{Name: "command", Type: "string", Required: true}, {Name: "timeout", Type: "number"}}}, + }} + }) + navigationDemo(c, func() { + navigationCheck(c, &s.disabled, "Disabled", log) + navigationCheck(c, &s.empty, "Empty", log) + }, func() { + tools := s.tools + if s.empty { + tools = nil + } + r := mujica.ToolRegistryPanel(c, &s.list, tools, mujica.ToolRegistryPanelOptions{Disabled: s.disabled}) + r.Element.Height(320) + if r.List.Changed() { + name, _ := s.list.Selected() + log("Selected " + name) + } + if name, on, ok := r.Toggled(); ok { + log(fmt.Sprintf("%s enabled=%v", name, on)) + for i := range s.tools { + if s.tools[i].Name == name { + s.tools[i].Enabled = on + } + } + } + }) +} diff --git a/examples/gallery/pages_motion_test.go b/examples/gallery/pages_motion_test.go new file mode 100644 index 0000000..4d3172f --- /dev/null +++ b/examples/gallery/pages_motion_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func TestMotionPagesControlsAndEvents(t *testing.T) { + controls := []string{"Confirm", "Lines", "Tool", "Empty", "Empty", "Disabled", "Loading", "Loading", "Loading", "Loading", "Loading", "Loading", "Add 500", "Append", "Thinking", "Replay", "Reverse", "Expanded", "Show", "Tick"} + if len(motionPages) != 20 { + t.Fatalf("motion pages %d", len(motionPages)) + } + for i, p := range motionPages { + t.Run(p.ID, func(t *testing.T) { + if p.Category != "Agent Ops & Motion" || p.Code == "" || p.Keys == "" { + t.Fatal("incomplete page metadata") + } + var events []string + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Column(c).Padding(16).Children(func() { p.Demo(c, func(s string) { events = append(events, s) }) }) + }, 1000, 700) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.SetDark(true) + events = nil + if err := tt.Click(controls[i]); err != nil { + t.Fatal(err) + } + if len(events) == 0 { + t.Fatalf("%s emitted no event", controls[i]) + } + if p.ID == "294" && !strings.Contains(strings.Join(events, " "), "Finished") { + t.Fatalf("completion events %q", events) + } + }) + } +} + +func TestMotionTypewriterRestartReplays(t *testing.T) { + finished := 0 + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + motionTypewriterDemo(c, func(s string) { + if s == "Finished" { + finished++ + } + }) + }, 1000, 700) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + before := finished + _ = tt.Click("Restart") + _ = tt.Click("Restart") + if finished != before+2 { + t.Fatalf("restart completions %d, want %d", finished, before+2) + } +} diff --git a/interrupt_button.go b/interrupt_button.go index fea58a9..0117d0f 100644 --- a/interrupt_button.go +++ b/interrupt_button.go @@ -18,7 +18,7 @@ type InterruptButtonOptions struct { Label string } -// InterruptButton stops a running agent; Changed reports the interrupt, confirmed when asked. +// InterruptButton stops a running agent; Changed reports the interrupt. func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) ChoiceResult { if opts.Interrupting && !running { panic("mujica: InterruptButton cannot be Interrupting while not running") @@ -41,7 +41,7 @@ func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) C if !live || b.IsDisabled() { *open = false } - clicked := b.Clicked() && live + clicked := b.Clicked() && live && !b.IsDisabled() switch { case clicked && opts.Confirm: *open = true @@ -50,7 +50,7 @@ func InterruptButton(c *ui.Context, running bool, opts InterruptButtonOptions) C } 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 { + if r.Confirmed && live && !b.IsDisabled() { res.changed = true } }) diff --git a/interrupt_button_test.go b/interrupt_button_test.go index d7cd259..42f58dc 100644 --- a/interrupt_button_test.go +++ b/interrupt_button_test.go @@ -74,3 +74,27 @@ func TestInterruptButtonSnapshot(t *testing.T) { }) }) } + +func TestInterruptButtonAncestorDisabledClosesConfirmation(t *testing.T) { + disabled := false + stops := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if InterruptButton(c, true, InterruptButtonOptions{Confirm: true}).Changed() { + stops++ + } + }) + }, 400, 300) + _ = tt.Click("Stop") + disabled = true + tt.Frame() + if tt.HasText("Stop the agent?") || stops != 0 { + t.Fatal("disabled confirmation retained") + } + _ = tt.Click("Stop") + tt.Key(0, ui.KeyEnter) + if stops != 0 { + t.Fatal("disabled interrupt delivered") + } +} diff --git a/layout_transition.go b/layout_transition.go index 3567d07..f7e7596 100644 --- a/layout_transition.go +++ b/layout_transition.go @@ -4,27 +4,28 @@ import ( "time" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) // LayoutTransitionOptions configure a LayoutTransition. type LayoutTransitionOptions struct { - // Duration of a change; zero means 200 ms. + // Duration of a change; zero means StateDuration. Duration time.Duration - // Row lays the content in a row instead of a column. + // Row lays the content in a row. 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. +// LayoutTransition animates its size and place when they change. 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 + d = theme.StateDuration } box := ui.Column(c).AlignItems(ui.Stretch) if opts.Row { @@ -33,7 +34,7 @@ func LayoutTransition(c *ui.Context, opts LayoutTransitionOptions, content func( if opts.Clip { box.Clip() } - box.Transition(ui.ElementTransition{Duration: d, Position: true, Size: true}) + box.Transition(ui.ElementTransition{Duration: Motion(c, d), Position: true, Size: true}) if content != nil { box.Children(content) } diff --git a/layout_transition_test.go b/layout_transition_test.go new file mode 100644 index 0000000..e5ecc77 --- /dev/null +++ b/layout_transition_test.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestLayoutTransitionResizes(t *testing.T) { + wide := false + var w float32 + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).AlignItems(ui.Start).Children(func() { + box := LayoutTransition(c, LayoutTransitionOptions{Clip: true}, func() { + width := float32(100) + if wide { + width = 300 + } + ui.Box(c).Size(width, 40) + }) + w = box.Bounds().W + }) + }, 400, 120) + wide = true + before := *n + tt.Frame() + if *n-before < 3 { + t.Fatal("resize did not animate") + } + time.Sleep(260 * time.Millisecond) + tt.Frame() + if motionBusy(tt, n) || w != 300 { + t.Fatalf("settled width %v", w) + } + defer func() { + if recover() == nil { + t.Fatal("negative duration did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, nil) + }, 100, 50, 1) +} + +func TestLayoutTransitionSnapshot(t *testing.T) { + snapshot(t, "layout_transition", 300, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() { + LayoutTransition(c, LayoutTransitionOptions{}, func() { + ui.Text(c, "A panel that grows").Padding(12).Background(Tokens(c).Surface).Radius(4) + }) + }) + }) +} diff --git a/loaders_test.go b/loaders_test.go index 0761011..49f5ebb 100644 --- a/loaders_test.go +++ b/loaders_test.go @@ -41,44 +41,40 @@ 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 testLoaderBehavior(t *testing.T, loader func(*ui.Context, LoaderOptions) *ui.Element) { + 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") } } @@ -107,9 +103,3 @@ func loaderSnapshot(t *testing.T, name string, loader func(c *ui.Context, o Load }) }) } - -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 index 8597473..3247d1a 100644 --- a/messages_motion.go +++ b/messages_motion.go @@ -48,6 +48,13 @@ func init() { "tool_registry_panel.enable": {"Enable %s", "启用 %s"}, "tool_registry_panel.required": {"required", "必填"}, + "dots_loader.loading": {"Loading", "加载中"}, + "bars_loader.loading": {"Loading", "加载中"}, + "pulse_loader.loading": {"Loading", "加载中"}, + "orbit_loader.loading": {"Loading", "加载中"}, + "wave_loader.loading": {"Loading", "加载中"}, + "shimmer.loading": {"Loading", "加载中"}, + "typewriter.skip": {"Skip", "跳过"}, "blink_highlight.up": {"up", "上升"}, diff --git a/motion_delivery_test.go b/motion_delivery_test.go new file mode 100644 index 0000000..f67cc27 --- /dev/null +++ b/motion_delivery_test.go @@ -0,0 +1,57 @@ +package mujica + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMotionDocsMatchExampleBodies(t *testing.T) { + src, err := os.ReadFile("example_motion_test.go") + if err != nil { + t.Fatal(err) + } + fs := token.NewFileSet() + file, err := parser.ParseFile(fs, "example_motion_test.go", src, 0) + if err != nil { + t.Fatal(err) + } + examples := map[string]string{} + for _, d := range file.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + body := string(src[fs.Position(fn.Body.Lbrace).Offset+1 : fs.Position(fn.Body.Rbrace).Offset]) + lines := strings.Split(strings.Trim(body, "\n"), "\n") + for i := range lines { + lines[i] = strings.TrimPrefix(lines[i], "\t") + } + examples[strings.TrimPrefix(fn.Name.Name, "Example")] = strings.TrimSpace(strings.Join(lines, "\n")) + } + count := 0 + for path, doc := range readGlob(t, "docs/components/*.md") { + id := filepath.Base(path)[:3] + if id < "281" || id > "300" { + continue + } + name := strings.Fields(strings.SplitN(doc, "\n", 2)[0])[2] + block := docsGoBlock.FindStringSubmatch(doc) + if len(block) != 2 || strings.TrimSpace(block[1]) != examples[name] { + t.Errorf("%s differs from Example%s body", path, name) + } + for _, section := range []string{"用途", "最小示例", "参数", "状态", "事件", "键盘操作", "限制"} { + if !strings.Contains(doc, "## "+section) { + t.Errorf("%s missing %s", path, section) + } + } + count++ + } + if count != 20 || len(examples) != 20 { + t.Fatalf("docs %d examples %d", count, len(examples)) + } +} diff --git a/orbit_loader.go b/orbit_loader.go index 6a32103..c7a0d0d 100644 --- a/orbit_loader.go +++ b/orbit_loader.go @@ -8,9 +8,9 @@ import ( "mujicaui/internal" ) -// OrbitLoader moves a diamond around a ring of small diamonds, in Spinner's language. +// OrbitLoader moves a diamond around a ring of diamonds. 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) { + return loaderMark(c, opts, "orbit_loader", 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 diff --git a/orbit_loader_test.go b/orbit_loader_test.go new file mode 100644 index 0000000..1a14aa0 --- /dev/null +++ b/orbit_loader_test.go @@ -0,0 +1,7 @@ +package mujica + +import "testing" + +func TestOrbitLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "orbit_loader", OrbitLoader) } + +func TestOrbitLoaderBehavior(t *testing.T) { testLoaderBehavior(t, OrbitLoader) } diff --git a/presence.go b/presence.go index 2eacd5a..98f4e9f 100644 --- a/presence.go +++ b/presence.go @@ -28,7 +28,7 @@ type PresenceOptions struct { Duration time.Duration } -// presenceMotion is where content of kind comes from and goes to. +// presenceMotion is where content comes from and goes. func presenceMotion(kind PresenceKind) *ui.Motion { switch kind { case PresenceFade: @@ -43,7 +43,7 @@ func presenceMotion(kind PresenceKind) *ui.Motion { type presenceState struct{ shown bool } -// Presence builds content while show is true; it enters and exits with motion, removed once the exit ends. +// Presence builds content while show, entering and exiting with motion. func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *ui.Element { m := presenceMotion(opts.Kind) if opts.Duration < 0 { @@ -61,10 +61,14 @@ func Presence(c *ui.Context, show bool, opts PresenceOptions, content func()) *u } holder.Children(func() { if !show { + if c.Preferences().ReduceMotion { + // Rebuilding the empty slot cancels MyGo's active exit. + ui.Column(c).Key("presence").Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) + } return } box := ui.Column(c).AlignItems(ui.Stretch).Key("presence") - box.Transition(ui.ElementTransition{Duration: d, Enter: m, Exit: m}) + box.Transition(ui.ElementTransition{Duration: Motion(c, d), Enter: m, Exit: m}) box.Children(content) }) return holder diff --git a/presence_test.go b/presence_test.go new file mode 100644 index 0000000..bb32fdf --- /dev/null +++ b/presence_test.go @@ -0,0 +1,85 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestPresenceEntersExitsAndRemoves(t *testing.T) { + for _, kind := range []PresenceKind{PresenceFade, PresenceSlide, PresenceCollapse} { + show := false + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { + Presence(c, show, PresenceOptions{Kind: kind}, func() { ui.Text(c, "Notice") }) + ui.Text(c, "Below") + }) + }, 300, 120) + if tt.HasText("Notice") { + t.Fatal("hidden content built") + } + show = true + before := *n + tt.Frame() + if !tt.HasText("Notice") || *n-before < 3 { + t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before) + } + time.Sleep(250 * time.Millisecond) + tt.Frame() + show = false + before = *n + tt.Frame() + if *n-before < 3 { + t.Fatalf("kind %d: no exit motion", kind) + } + time.Sleep(250 * time.Millisecond) + tt.Frame() + if tt.HasText("Notice") || motionBusy(tt, n) { + t.Fatalf("kind %d: not removed after exit", kind) + } + } +} + +func TestPresenceReducedMotionAndInvalid(t *testing.T) { + show := false + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { Presence(c, show, PresenceOptions{Kind: PresenceSlide}, func() { ui.Text(c, "Notice") }) }) + }, 300, 120) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + show = true + if motionBusy(tt, n) || !tt.HasText("Notice") { + t.Fatal("reduced motion animated") + } + defer func() { + if recover() == nil { + t.Fatal("unknown kind did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1) +} + +func TestPresenceSnapshot(t *testing.T) { + snapshot(t, "presence", 300, 100, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Presence(c, true, PresenceOptions{}, func() { ui.Text(c, "The doors open.") }) + }) + }) +} + +func TestPresenceReducedMotionCancelsActiveExit(t *testing.T) { + show := true + tt, n := motionFrames(func(c *ui.Context) { + Presence(c, show, PresenceOptions{Duration: 5 * time.Second}, func() { ui.Text(c, "Leaving notice") }) + }, 300, 100) + show = false + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionBusy(tt, n) || tt.HasText("Leaving notice") { + t.Fatal("active exit continued under reduced motion") + } + tt.SetPreferences(ui.Preferences{TextScale: 1}) + if motionBusy(tt, n) { + t.Fatal("exit replayed after restoring motion") + } +} diff --git a/pulse_loader.go b/pulse_loader.go index a500bcf..69535c8 100644 --- a/pulse_loader.go +++ b/pulse_loader.go @@ -6,9 +6,9 @@ import ( "mujicaui/internal" ) -// PulseLoader shows one dot breathing between 60% and full size; never a halo. +// PulseLoader shows one dot gently breathing, without 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) { + return loaderMark(c, opts, "pulse_loader", 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) diff --git a/pulse_loader_test.go b/pulse_loader_test.go new file mode 100644 index 0000000..990fdf0 --- /dev/null +++ b/pulse_loader_test.go @@ -0,0 +1,7 @@ +package mujica + +import "testing" + +func TestPulseLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "pulse_loader", PulseLoader) } + +func TestPulseLoaderBehavior(t *testing.T) { testLoaderBehavior(t, PulseLoader) } diff --git a/reorder_transition.go b/reorder_transition.go index 9b27221..d96ccdd 100644 --- a/reorder_transition.go +++ b/reorder_transition.go @@ -2,28 +2,30 @@ package mujica import ( "fmt" + "math" "time" "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) // ReorderTransitionOptions configure a ReorderTransition. type ReorderTransitionOptions struct { - // Duration of a move; zero means 200 ms. + // Duration of a move; zero means StateDuration. 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. +// ReorderTransition slides keyed items to their new places. func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransitionOptions, item func(key K)) *ui.Element { - if opts.Duration < 0 || opts.Gap < 0 { + if opts.Duration < 0 || !(opts.Gap >= 0) || math.IsInf(float64(opts.Gap), 0) { panic("mujica: ReorderTransition Duration and Gap must not be negative") } d := opts.Duration if d == 0 { - d = 200 * time.Millisecond + d = theme.StateDuration } seen := make(map[K]bool, len(keys)) for _, k := range keys { @@ -32,12 +34,33 @@ func ReorderTransition[K comparable](c *ui.Context, keys []K, opts ReorderTransi } seen[k] = true } - move := ui.ElementTransition{Duration: d, Position: true, Enter: &ui.Motion{}, Exit: &ui.Motion{}} + move := ui.ElementTransition{Duration: Motion(c, d), Position: true, Enter: &ui.Motion{}, Exit: &ui.Motion{}} col := ui.Column(c).AlignItems(ui.Stretch).Gap(opts.Gap) + type state struct { + keys []K + leaving map[K]time.Time + } + st := ui.Local(col, "reorder", func() state { return state{leaving: map[K]time.Time{}} }) + for _, k := range st.keys { + if !seen[k] { + st.leaving[k] = c.Now().Add(d) + } + } + st.keys = append(st.keys[:0], keys...) col.Children(func() { for _, k := range keys { ui.Column(c).Key(k).AlignItems(ui.Stretch).Transition(move).Children(func() { item(k) }) } + for k, until := range st.leaving { + if seen[k] || !c.Now().Before(until) { + delete(st.leaving, k) + continue + } + if c.Preferences().ReduceMotion { + ui.Column(c).Key(k).Absolute().Invisible().Transition(ui.ElementTransition{Duration: d, Position: true}) + delete(st.leaving, k) + } + } }) return col } diff --git a/reorder_transition_test.go b/reorder_transition_test.go new file mode 100644 index 0000000..43695ff --- /dev/null +++ b/reorder_transition_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestReorderTransitionSlides(t *testing.T) { + keys := []string{"a", "b", "c"} + tt, n := motionFrames(func(c *ui.Context) { + ReorderTransition(c, keys, ReorderTransitionOptions{Gap: 4}, func(k string) { + ui.Text(c, "Item "+k).Padding(8) + }) + }, 300, 200) + ra, _ := tt.Find("Item a") + keys = []string{"c", "b", "a"} + before := *n + tt.Frame() + if *n-before < 3 { + t.Fatal("reorder did not animate") + } + mid, _ := tt.Find("Item a") + time.Sleep(260 * time.Millisecond) + tt.Frame() + end, _ := tt.Find("Item a") + if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) { + t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y) + } + defer func() { + if recover() == nil { + t.Fatal("duplicate key did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {}) + }, 100, 50, 1) +} + +func TestReorderTransitionSnapshot(t *testing.T) { + snapshot(t, "reorder_transition", 300, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ReorderTransition(c, []string{"Sakiko", "Uika", "Mutsumi"}, ReorderTransitionOptions{Gap: 4}, func(k string) { + ui.Text(c, k).Padding(4, 8).Background(Tokens(c).Surface).Radius(4) + }) + }) + }) +} + +func TestReorderTransitionInvalidGap(t *testing.T) { + for _, gap := range []float32{float32(nanValue()), float32(math.Inf(1))} { + func() { + defer func() { + if recover() == nil { + t.Errorf("invalid gap %v accepted", gap) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ReorderTransition(c, []int{1}, ReorderTransitionOptions{Gap: gap}, func(int) {}) + }, 200, 100, 1) + }() + } +} + +func TestReorderTransitionReducedMotionCancelsExit(t *testing.T) { + keys := []string{"a", "b"} + tt, n := motionFrames(func(c *ui.Context) { + ReorderTransition(c, keys, ReorderTransitionOptions{Duration: 5 * time.Second}, func(k string) { ui.Text(c, k) }) + }, 300, 150) + keys = []string{"b"} + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionBusy(tt, n) || tt.HasText("a") { + t.Fatal("removed item kept animating under reduced motion") + } +} diff --git a/shimmer.go b/shimmer.go index 174a960..3c2eab6 100644 --- a/shimmer.go +++ b/shimmer.go @@ -19,7 +19,7 @@ type ShimmerOptions struct { Label string } -// Shimmer shows a sweeping placeholder while loading, then builds content instead. +// Shimmer sweeps a placeholder while loading, then builds content. 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") @@ -38,7 +38,7 @@ func Shimmer(c *ui.Context, loading bool, opts ShimmerOptions, content func()) * } label := opts.Label if label == "" { - label = msg(c, "loading") + label = msg(c, "shimmer.loading") } fill := k.SurfaceHover if k.Dark { diff --git a/shimmer_test.go b/shimmer_test.go index 7479e93..e04ac50 100644 --- a/shimmer_test.go +++ b/shimmer_test.go @@ -55,47 +55,3 @@ func TestShimmerSnapshot(t *testing.T) { }) }) } - -func TestShimmerTextSweepsAndStops(t *testing.T) { - active := true - tt, n := motionFrames(func(c *ui.Context) { - ShimmerText(c, "Thinking", ShimmerTextOptions{Active: active}) - }, 200, 60) - if _, ok := tt.Find("Thinking"); !ok { - t.Fatal("text not named") - } - a := encode(t, tt.Image()) - time.Sleep(200 * time.Millisecond) - tt.Frame() - if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { - t.Fatal("no sweep") - } - active = false - tt.Frame() - if motionBusy(tt, n) || !tt.HasText("Thinking") { - t.Fatal("inactive text still sweeps") - } - active = true - tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) - if motionBusy(tt, n) { - t.Fatal("sweeps under reduced motion") - } - defer func() { - if recover() == nil { - t.Fatal("empty text did not panic") - } - }() - ui.Render(func(c *ui.Context) { - Use(c, Settings{}) - ShimmerText(c, "", ShimmerTextOptions{}) - }, 100, 50, 1) -} - -func TestShimmerTextSnapshot(t *testing.T) { - snapshot(t, "shimmer_text", 300, 80, func(c *ui.Context) { - ui.Column(c).Padding(16).Gap(8).Children(func() { - ShimmerText(c, "Thinking…", ShimmerTextOptions{Active: true}) - ShimmerText(c, "思考中…", ShimmerTextOptions{}) - }) - }) -} diff --git a/shimmer_text.go b/shimmer_text.go index 1b7b9bd..d22d6f4 100644 --- a/shimmer_text.go +++ b/shimmer_text.go @@ -10,7 +10,7 @@ type ShimmerTextOptions struct { Active bool } -// ShimmerText shows a status such as "Thinking" with light sweeping across its letters. +// ShimmerText sweeps light across a status such as "Thinking". func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Element { if text == "" { panic("mujica: ShimmerText needs text") @@ -18,7 +18,7 @@ func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Elemen k := Tokens(c) still := c.Preferences().ReduceMotion if !opts.Active || still { - e := ui.Text(c, text).TextColor(k.TextMuted) + e := ui.Text(c, text).TextColor(k.TextMuted).SingleLine() if opts.Active { e.Role(ui.RoleStatus) } @@ -26,7 +26,7 @@ func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Elemen } runes := []rune(text) size := c.Theme().FontSize - // The text keeps its layout and name; the sweep is painted over it. + // The text keeps layout and name; the sweep paints over. e := ui.Text(c, text).TextColor(ui.Transparent).Role(ui.RoleStatus).SingleLine() e.DrawOver(func(p *ui.Painter, r ui.Rect) { p.AnimationFrame() @@ -37,7 +37,7 @@ func ShimmerText(c *ui.Context, text string, opts ShimmerTextOptions) *ui.Elemen 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...) + p.Clip(r, 0, func() { p.RichText(r.X, r.Y, 0, spans...) }) }) return e } diff --git a/shimmer_text_test.go b/shimmer_text_test.go new file mode 100644 index 0000000..cf49d9b --- /dev/null +++ b/shimmer_text_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "bytes" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestShimmerTextSweepsAndStops(t *testing.T) { + active := true + tt, n := motionFrames(func(c *ui.Context) { + ShimmerText(c, "Thinking", ShimmerTextOptions{Active: active}) + }, 200, 60) + if _, ok := tt.Find("Thinking"); !ok { + t.Fatal("text not named") + } + a := encode(t, tt.Image()) + time.Sleep(200 * time.Millisecond) + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) || !motionBusy(tt, n) { + t.Fatal("no sweep") + } + active = false + tt.Frame() + if motionBusy(tt, n) || !tt.HasText("Thinking") { + t.Fatal("inactive text still sweeps") + } + active = true + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if motionBusy(tt, n) { + t.Fatal("sweeps under reduced motion") + } + defer func() { + if recover() == nil { + t.Fatal("empty text did not panic") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + ShimmerText(c, "", ShimmerTextOptions{}) + }, 100, 50, 1) +} + +func TestShimmerTextSnapshot(t *testing.T) { + snapshot(t, "shimmer_text", 300, 80, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(8).Children(func() { + ShimmerText(c, "Thinking…", ShimmerTextOptions{Active: true}) + ShimmerText(c, "思考中…", ShimmerTextOptions{}) + }) + }) +} + +func TestShimmerTextConstrainedWidth(t *testing.T) { + var background ui.Color + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + background = Tokens(c).Background + ui.Column(c).Padding(10).Children(func() { + ShimmerText(c, "Thinking about the complete program and planning the next steps", ShimmerTextOptions{Active: true}).Width(80) + }) + }, 500, 100) + for _, reduce := range []bool{false, true} { + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + img := tt.Image() + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 92; x < img.Bounds().Dx(); x++ { + pixel := img.RGBAAt(x, y) + if pixel.R != background.R || pixel.G != background.G || pixel.B != background.B { + t.Fatalf("text escaped 80 DIP width at %d,%d", x, y) + } + } + } + } +} diff --git a/stagger.go b/stagger.go index 1b39ab1..7d0613e 100644 --- a/stagger.go +++ b/stagger.go @@ -20,13 +20,11 @@ type StaggerOptions struct { } type staggerState struct { - born time.Time - shown int - done bool + born time.Time + 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. +// Stagger builds n items entering one after another, once. 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") @@ -44,11 +42,14 @@ func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.El } st := ui.Local(box, "stagger", func() *staggerState { return &staggerState{born: c.Now()} }) s := *st + if n == 0 { + return box + } 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())) + box.DrawOver(func(*ui.Painter, ui.Rect) { c.After(s.born.Add(time.Duration(upto) * every).Sub(c.Now())) }) } else { s.done = true slog.Debug("mujica: stagger done", "items", n) @@ -64,7 +65,7 @@ func Stagger(c *ui.Context, n int, opts StaggerOptions, item func(i int)) *ui.El 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) }) + Transition(ui.ElementTransition{Duration: Motion(c, d), Enter: enter}).Children(func() { item(i) }) } }) return box diff --git a/stagger_test.go b/stagger_test.go new file mode 100644 index 0000000..30475db --- /dev/null +++ b/stagger_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestStaggerRevealsInTurnOnce(t *testing.T) { + count := 5 + tt, n := motionFrames(func(c *ui.Context) { + ui.Column(c).Children(func() { + Stagger(c, count, StaggerOptions{Interval: 50 * time.Millisecond}, func(i int) { ui.Textf(c, "Row %d", i) }) + }) + }, 300, 240) + if !tt.HasText("Row 0") || tt.HasText("Row 4") { + t.Fatalf("first frame: %q", tt.Texts()) + } + time.Sleep(120 * time.Millisecond) + tt.Frame() + if !tt.HasText("Row 2") || tt.HasText("Row 4") { + t.Fatalf("after 120 ms: %q", tt.Texts()) + } + time.Sleep(300 * time.Millisecond) + tt.Frame() + time.Sleep(250 * time.Millisecond) + tt.Frame() + if !tt.HasText("Row 4") || motionBusy(tt, n) { + t.Fatalf("did not finish and settle: %q", tt.Texts()) + } + count = 6 + tt.Frame() + if !tt.HasText("Row 5") { + t.Fatal("item added after the sequence waited") + } +} + +func TestStaggerReducedMotionAndInvalid(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if c.Preferences().ReduceMotion { + Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Row %d", i) }) + } + }, 300, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if !tt.HasText("Row 2") { + t.Fatalf("reduced motion staggered: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("negative n did not panic") + } + }() + ui.Render(func(c *ui.Context) { Use(c, Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1) +} + +func TestStaggerSnapshot(t *testing.T) { + snapshot(t, "stagger", 300, 140, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Guest %d arrives", i+1) }) + }) + }) +} + +func TestStaggerEmptySettles(t *testing.T) { + tt, n := motionFrames(func(c *ui.Context) { Stagger(c, 0, StaggerOptions{}, func(int) { t.Fatal("empty item built") }) }, 200, 80) + if motionBusy(tt, n) { + t.Fatal("empty stagger animated") + } +} diff --git a/testdata/snapshots/darwin/tool_registry_panel-dark.png b/testdata/snapshots/darwin/tool_registry_panel-dark.png index 0dc412fa52cd5851b7cd0f123cbfce59ed227b87..b0e7ebb7a39a1a3e3286a9d8408af0090cc33c1b 100644 GIT binary patch delta 2345 zcmY*Z2{e=q7k+2v%To4ik+Eb~cFIh$rX*`4WJ#25vSeST1 z$}qM>63SpKVT8|^_tXFVoxlIwbMLw5-g};N&pr2fiiogGA}n1R=0KDwI1Q581jTfb zVb>N4(p{m}%alCLYoBAc2IhiM6aWT;sa3`#M!quAox~UYu(Djl7bOdl)wPF_qv`iU zX-nhB39QsI)9Eiau;n&Y9BBnAf?<3;X|XLWJ$BHRevskjr=Z+zI@4By4L%^)@w7|T zcZJ8ykt3dqA7wSGkJktX4L!*VRnN4~i{J+oa_GFMM~@Z*n+VUIxhO8m9d&KJ>h108 z>+7k0AUj*jIA3sr8HTt+SOzy`C_jYz@@j_ie`Z~V4s6gij79&2MEu4^#FJy<$#-m* z)o1BjYXo6rfae-Rq-pYoCDcZ6Z}4 zdrYh=@CxTIxX$3*%9+cx zS_W`&R#%A}?Tp&fC27E{@_ugYc5N{0c4ry(B7=&?Vw1&Rj#aiG!H)B!pMF^Ei)MHz zgQ1n%fwy{lS$*p{LJtm9W9O0BQ3KmY0$xXUhv&JUwlLog?JoJN`ar9}JXMSj^7PS5Uk(DlA8v zXcEAz~2s*WUsZ_($i>qW~b6iz(k2CIs;CwFKCo zmQHOy{H{4}D>cB=DxO`O{+m4rj~l-9Zq4kaH530b9g+2t@MM%Rn?b8bww za5ICJjxTvJRuZ5hohWv|)y)la&JpU)grcszgKRMvK14(KZf0KGfWcc4oxb}bO{7CZ zQ!?W;x9DN{wy2#$tE8fru50uo3(k=Lnn8()qq`Uo(>^trB>-pz z$0u?Q@Ub}Ug^Cw)qkUHW)8PY%J?(xb&V`0tF^U$d1cEb zE(=%v8v+7?kSCg}OeFlU)(QeA!)kj$>b}QqD-GY{_Y0CLY!oaRjl>F4K={bFzB!_~ zR&0!^F7Nsdm&s{qmgHMN92s;|(;+D*345r-ecE^F=PMzpD9PVKcK7_Oaa@|3YTr4b zo5}UJlIx9_EmMUrxFRE(&GR%}qIlwvmgkr&4=!pY1z;4jZN%EkY<%{eDeoV7s~^UW z{LC-N2FNQb4zL%RPcSwqtdOU_r`Q2rk4t3mX`ci0ljT)i1nA_^6rOrtRTr&w!VluU zClYss_2WU|1RwXW%T7U}0_yJTto(|Lo7~0OGiCdd1N&8qkw-lhPEAyn*Ur<)5F7}6 ze32OVljEMIsI9{LHT7M&-0TAA=#kf5*vZJ_wKvH5jpd#b;FsEm^0I<#FxUzMu69(5 zHbD1E`MKHy9!Z2qlY0J}z1ziG)WyZGfl4a4%9bCH#fosyUk29nm&2{>;S5x)Ym4lg zN9t#vr3Cmq3la5Lo{QRHSeKK~!6r4!BUKZvVx?nTG!0=szSW4YK=I8!0^$hjKVio{ zn5DSokvfPD(CFR4xyz3_Hfj#BdDd>dRZ?~GmRwf<=IF8$9k#p@&0=I|__8GKx1w&n zi6iToRW-VgHQOCN=;YwkQZavLIN<_;6%Kcs1%B%O| z^$#f=8izVQyiI$T!okkYzBR>%<1tjmK?ZueuEUR131$|Lql5tf(3hC;A3v0m--VmB zE6ucz%e2P`$Z-`Kc`l5!@3sNB8c0K4`mAYEpJ5XP!G3&<&>gbIm{tccw<@8SP~)%7#!a@Wu@aI}-mQE|x=3t^kpU#01AFU}h8* zy*@6i6*1sZpC>2t>(wwUFtG4BqA<&bsbJbMcejHxSf4dGfvu3hCi%@YGr@{Y^3&|d zu94PK$q}XdEs1~I&-|)8pI_l$qpQx^CpqpK^d@^V8;Tj*Su#Vv!%1`Ed#6KROnjfa z)Th&V%=>jjCY>bPv&oYd(LCrg@VvAlpFCe3nE0LRy;u&~t0wnl$6m?n)$@KR?5Q4~ z0c!8Z(6zv*CYR{tnUFbpa3o5%I5M&#i)*FP2_lOt5BW3sPg2ZVnNEz#ZpHAr>34VK z&_xTR8C)UVKTXW?+KY*u`CMwpV#TZHTT)Ptw5bS2e2Ib1z>6UY^YOSz+e(oj@n9ww Y5Psj)Y5Mr>=3fUlJ!xTFYv>yL4{_sz0{{R3 delta 2346 zcmZWp2{hDe8~;nz@Db7#Q`xu4lBGKnGlr2R`^ZqqQu!z(W6ScN8cVjwWC>&In-CdF zh$M9F1_{lKaw(Z1N+U6txjMJb_ucco&-*;*ci!hYzw@5=yuV*|JzsG>UzQ5rg?iN@ zYa-s-{AcQdRD|#7==)H)9;%4Qn!3DTf{kN_$h^Uqkv-vQ`mTYkR>T~RrjUhsGZO!=H_B=m;$7sQ0mJLR?5|EO+ zyY$Kh6D4F|PE2GO|1ZFHPoNgjT*08A8befts}4&U=XshjcNHBJ)P~Vgrb7t1xf2r; z5o$zd4-bomfR!cCq*Q~$O+L3*Jd=;q=h+tEXtP!l+8*!NVE$j)pZsE2W)1e+5rRVr zLKhY>db#|ekchhr(KWadqa~wQ>48>RC^~lGFn|QfE>c0B=^~2T-tU!5vj(bD+49;0 zDM=?f`g*3bLhiw_!lh*P<;Je|O-6~} z-SY<1ij7yi%tNiSz(6|mHdet%RY{*km*Z_v>Gav;&&Gk2o;q1d8v~@%Yisd}2ykfb ziiV8A-ILs<7nuNlp`fLwAi^o<>xW@}BVIn^DHa}M=2~WP-^tBZz?py&5>?c|m5yBh z8x+TsvGue*h^AQD-g6<5mf&zz%cFWm`i+mD%ofUzu~sXqq+42A0u1TtEMmkm?B)Zf z6iZRy3a;(~sK>;?GMa_!XqP3DGyVh=Bn|*C&KumMa_iUML(jx114ygR08(ry#0=&= zThqNDIz0NGE+05X<(|qV`kW6o{eG@Q|MxSZiH+CBAixVU0U^5lRscw_)K%EG*TzrJ z?oserSe-o?vkT~^#)o6CB-oO5dac6`8eN=Jr)YvLtsKQqW?HzdXR-jkVJtZ*lXN-Y z^EKsVY=7VOOL?Eyvgs{1oZa;nM6J$tRBG=J|K@9TmUKLBB}bLl6tO*T*%cS{nZPT8 zXTs&dXEW{oRIgn?e*XUP*AwlJKJdIezJju{IWLjWA85=U>ZWl=Bh{hg364`k=3R>c z`A2DpS_NA4a^8?js1V_g!*n2{qak9&puVB?>iIldvI2b54Zt@)+33GbntR&BkiSIP z8K3K=c5B5p4JdnG{?d?Nb0@72+*2JlC0rcI6TKJ)P#C6!NXlmZKRR4cV)^+lPSkN+uTv|d>iw+nlf68eJb7ey*n z(UzU`$#qyZu6+jpm*j?XIScC+hyYZJlH9}Sm);%~))Sy+CvfVV$J%COrt`&r2$VmO zDk%ly9fG5S$|uwSWQ?onkva`bd4=wq<8gapCV7ll$;#0QtPtQ`cg*O508;$yj1M7P zqmM(>K>eQ}SJiP&7<5@yhM2ggC!-k4#3xsbn`do;YjbF*{ake9tvFstIbX#yyNff- zq&z_Hakx^!=D`}jupm`t6aPQHd+A7YaUxadb9>)R9Z6vc0Hmb+J}F*)@E|a98Fmb+ zrF7^;-jMGs!}Qcg9&O#MSa_aMlCsFTP9l9vooharo=E-;@ZN5jBpcBF0Mk+?L(W%j znxoMIK+oJ+1y-_cv@I~bHl;9XpYK`D9c{u>(pMq{j9qBv>FRuDFcoj+Sxl z1gX6#9kYjkvd7qy5)?a7DT%hnXAPC#_9d~`OZb=5+>?b#&G?Clt-eP8Is0j6XMvQc z`C5Wkaj>ge#w28SS4Mxu15Hd*=*af7f_2W?f zVz$!6To25Q@tM*Q8Gm19=`tyzYT&Cx;y}G8sJn`+`2S4kMwYGWz~? z_~evT;vjaLFVPmx?eZu8&gv{LS{~KH3_V$hPAQV0sPdWdrgrkzuJ(MTmRQ* zCoP_<#_I#y5{;oNp3RWV>JqkEFpKuRq-jwrHAMi0L>_NP4%!cK3{BK{-E)ZJ=#J>IQg?j+Mj`4+MZ(thg@meLnmEk=YY7Zn7(O`^qc zybvHp^Rom=Y7ri>@I9o5+1SMqOG-$Hg&cd~f2e_pOq2Lg91#0R9sVKJgVPdIgJHZ6 z`%~BU9kDm+ynD&)tEiZJZ>!iwF6a(Uzm174>B!2;($y&Y<)~c!GWM{G*7BRiW!XJK z?)%&+RW0RD%5s_4@%a5P{4f8Fwn@B2r&v3?6-_z{?cV*;iEl7n?5J|rV6WcMuYLx~ z7AO?zd2_9QlYyjJB+U0xX&Ork=pJ)8FmO zoiOce4oD~JyABieTnA(w&ft!~{F)>Lce;`{H%@;Set%J)IO?{3yOeM=#bSQsx7LV} za$`wB?_**Uz+)DePJ~=!O}orOlge2&^@Cb(s)X+>^7hQQ25w zFhmHZN#1hJP8w58KET9FFvX-`Xem@LQANB=67^`bekY>m50Kba>czg_>IZ+Ex75sP zeq{NK)bDND30|i5iZ+k0Y4dGfM7Fw(JgTH;DWZK~tgAHaX6UPQwhAlSzF_4rgpI2j zzaMhK-h?#nHYYKlm6SoS+?+=$ymOjp{U2WsM8(MtDVy)t? znSEP5si`;{`jksToOv+4rI}h@7{C2=-8skY%lRInqGQByTJ(@QoHY%B7tcntZ;tB@ zCo}95L)d68)F#`o(rjuYk~rfI*=1ru_C~znM diff --git a/testdata/snapshots/darwin/tool_registry_panel-light.png b/testdata/snapshots/darwin/tool_registry_panel-light.png index 8ed4916e875ad94ddfa4bf0ea5271ff28c173ee1..8adbb483c6b907bef1232e6829a15f4c6527f48b 100644 GIT binary patch delta 3326 zcmcguc{tST7ble^p=39N%91U~GKiWXCCk{e3_@hB43Xtq$rf1}$E2YfkSmtD@ZPve* zALKa&kiso3#zCwz3|Q}QeYf*JZlGZAXtafu@KA15Rn@0) zE(Mi~XWus7u1#t@$+JCVz7e7`Yi6qbnwNXwnf7a3ktf=Ybm4gZj$PE4DALks%=js~> zrKLSRgG7T=&hcF#Pht6Q<9hAWzG!-{X%>9c2VU+HtWyAUwXK^KaG<(ugs;` zE|V?*syidpN&VI1wAh~@x3HqhIZ-hM&z^-fMeC+$mX`+* zvEfNwZ!2(~-Xm&bMoQFi0Y!L8YPBI_jw9`WfvPEgtoo*>9{j7H)4<0ONY&_wU={Gc6N9G(}QNTlhMM;8L>fD z+2ruiFfxj{x$Auuk`g?Z2Mq4&G!+mp7Kn!`wVs;7f}Per9OiFYAEsm)DQFHC$I67` zDgC#PE*OLSWzg_!4dl8A^Ebp0LiJ`G)4D*M60CF(<*Q;eq&JYFQ=3K67v zZEcCL>S|+C`)5$4Gqa>pLOzZrA#% z)wiM(tel0yb|?^tSfEW9Z;Ps9k=BhEcUQk3cTTnk1P)m~9OyAb!9z!W`t6vrzL7dMBZWkQ0sio5Shyydy!mO~Gm| z>^%YAY0X;3f}d&1^ns+H5t0eVY&L>kytwGCezH#bTouwwWZ;O)@mbhO&^Wcg1sUIFE%JCm{18&yX(G0~yA{ za39b}Hsl>_`&SIVL+M7cq3WAgE9|D5<99<5Q^6J1#S04yr_)P|ifb>?eNc^_2XcvN z#5&BdMxuR2-AzXiDIFc1(4fx;B`a!Bv5dK0A&H@fBK(}4uNNF*=O`-C&IB@Rz;a&E z=w*vH1)DL|wcr8uz%WwZ^t3GB>&Ig0wVm`tGL?wNer8dXk<%lb9 zk!;@{8SaHy+x+zMAn%aDJ8%GRZ`~09RlREP-stk4t@e5eNNz;Ec`T3#z7-$mqi9{> z7^)C@KjQh!r&C%XiHF;4sUKzC#bfukAw}raiVH#7CC?nN-0>07CgAoTeG!;7$2WL#L|2NP8Ck4-&9g=c>e3Qq)a8{ zKWXeL4u0XJw?|i@H+~De%Fd>wsbN=V!1?#3-N5_}(f*?TA06-m1@Mh;2;{GM_s7`# z@dEhqA^tT2@ZIb1uckl568Qc<@PBE`X^?S6#727}h?9wlX+O_g6@5>Ou`4u&#skWQ zAY!@tB*6bvfd78b|2Za|+k0* zNx`HfV^fmFRZT>}G2L%)_esxg$S^f=z9_ec6v-{Dqf4~Z=Fba7#w+nz@LjA_&~N4- zE}`$3=E_U2s3K2T%^=OJ1OR_lu*9u-+0%0qiGbfj7VyKPrnHyAr?U>c$`MrhqO*Vr zIk*l6x+z)hbSA`=jCrc<%N=RL*4Zri$oQAk=1JzY)qP_twt&%>JN-Fw9f3n)J5lmV zLyt_d^4o9wS`bF{?KUm(%a^>Kco9TaaO(2@wj*VAE;b5>!~SA8w+W%;hm zVxp*%v+4zELW3>9vT*ptUH z`5atkA!a*AIOJgO9s=q`rhY{_x)Yh``p+4JlFmE-S<7>j+N3KH^f=3 z9^RAW)de!}#^wWH3a~V4p=I4v##xrUBoke|OCkDzw1c)=d_k(aUrxWJfhTuPyISg_JOxAV0-ZszJYJpd>4UCT}SM0_U49q4mZrKwUhOoB7~VA zsn1=hoIObF(W&bp+`Kz+Jqg*3sA7-9m|taYit;ZUBa2&JoE}e11nk?LI|)TG<)ggQ zGqsfqgb1BHdDNT&#!>nEUn&X zeZfFQ1s5e$d>h%5@n%F4nCG%8MK<8dG2>0>R}zqz>R0jpL0tgkoogE=&4x(BdWcZ& zJTuU?I_5D`8nAu{KMX3@U*9dfCoWV53}@aI--Ynk=~a$+XAfoH&O`C)?-UCt4_CUU zQYsf$6e|d1o$iVCQ3~jN-!iU{MJ+3@V&mZO21J&sGMjo*u%vpD;l1JKHk#C19=_}Ah zo!&3mK%zdn_68)Sfm_-uM2MJ(77DUL`00W5ah&IqvVCE{ zOwj4A^^5bJs(|fC za!Uk2DaOyezx}atu+^FD&F`CEAn%lYg$)k&zk*o#P{3g`9!-qyaU%^enW7FwvAh>H9Oqlh54YJwF zyV(&zV%W%}^tSR}U=&PAG#-Fri zgb5>%4PUl6QMw&Iww+-U5+<_aYdoY2d9Ofm-6O`0ZK@4wzO+8H{k1g)th4M{Fv{gP z-_HF`U!<8C(ACyCTF~8R6K3v5s#rdMM}IF^)!xFy+i@Zk)dG#vG0Tnv?q!7s)H(iU zqnp9Bm-BX9fejC<&LPP*jU9p;VFw5Fh@cz>BhmN$%B;PGc>u0j4GQg7qRAR{^v)1% zWU;SDG&I^abp70~^fbMVf;kFQ2gX%Sg{+7yEL~ydfkRJBOeJdT1KOmNVaT)SN*jac zUf5M>+a7^zsoWaHHG3e&(!~>&!nqb@Vg~E0ij0b2&n&svuz0lGPY{zZGO&N*GzC9;8&AQ8Fz=lqTd&_P8}xi1dFf^`p$S>`(Ou6Ip+aBq-U ztiv)8=dZTw+R0nSNBZ()K3ffgwDtElvfCOr|8=7uR05ld{y|vZ0|jh%*q^`y*gj;w zI{iP(1EowGVfz|;en9^D3iVy@|AYMZUFZIqG=I+dGo?moBk)b>1OJZZzA^RfV`Vdc zZm<9C64;8@KZghK&rLsElO`2PTZ>Z14;cxEv&uad6CuMxicH5g+G?Fn!II^xmrdpQ zE#h2;P0dTr`)>>_OSE6gMJXo4nGYu9m_q@EPmaLJ)7kOIvgekm?1&r?f)Rp@PN~rA z5s92<-K=vM!iZwo4VB#&6Y^D#ExwTM=v|bfg^+Az9iK}WovAzcpb=$4jAd4B?m>Wy za%~B8hpx7TT#uOwy|j`+C8mL0J`QNmtYfJEi^kyeGEQxdhYT`>xS1YvlP_itP!?-}ZVPWCuG%%eUTiO`XgHI1&QQ!K{p@ n32~O82{$ao57h7=8rfO0MbzpFtX?X!e0ub>ky^x)=Y#$RBB88; diff --git a/token_usage_chart.go b/token_usage_chart.go index 11e139e..dd3c326 100644 --- a/token_usage_chart.go +++ b/token_usage_chart.go @@ -14,20 +14,23 @@ type TokenUsage struct { // TokenUsageChartOptions configure a TokenUsageChart. type TokenUsageChartOptions struct { - // Frame names, sizes and states the chart; an empty Label uses the built-in name. + // Frame names, sizes and states the chart. Frame ChartFrame - // Line draws input and output as lines instead of stacked bars. + // Line draws lines instead of stacked bars. Line bool + // Version changes when usage is edited in place. + Version int } type tokenUsageSeries struct { - src *TokenUsage - n int - labels []string - series []ChartSeries + src *TokenUsage + n int + version int + labels []string + series []ChartSeries } -// TokenUsageChart shows input and output tokens per session or step, as stacked bars or lines. +// TokenUsageChart shows input and output tokens per step. func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOptions) ChartResult { if opts.Frame.Label == "" { opts.Frame.Label = msg(c, "token_usage_chart.label") @@ -39,18 +42,30 @@ func TokenUsageChart(c *ui.Context, usage []TokenUsage, opts TokenUsageChartOpti 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)) + if s.series == nil || s.src != first || s.n != len(usage) || s.version != opts.Version { + step := max(1, (len(usage)+255)/256) + n := (len(usage) + step - 1) / step + in, out := make([]float64, n), make([]float64, n) + s.labels = make([]string, n) 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 + at := i / step + in[at] += float64(u.Input) + out[at] += float64(u.Output) + if i%step == 0 { + s.labels[at] = u.Label + } + if step > 1 && (i%step == step-1 || i == len(usage)-1) { + s.labels[at] += " … " + 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) + s.src, s.n, s.version = first, len(usage), opts.Version } + s.series[0].Name = msg(c, "token_usage_chart.input") + s.series[1].Name = msg(c, "token_usage_chart.output") format := func(v float64) string { return strconv.FormatFloat(v, 'f', 0, 64) } var res ChartResult holder.Children(func() { diff --git a/token_usage_chart_test.go b/token_usage_chart_test.go index 4e6d21a..0096501 100644 --- a/token_usage_chart_test.go +++ b/token_usage_chart_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strconv" "testing" "github.com/egoist/mygo/ui" @@ -62,3 +63,46 @@ func TestTokenUsageChartSnapshot(t *testing.T) { }) }) } + +func TestTokenUsageChartLocaleRefresh(t *testing.T) { + locale := EnUS + usage := tokenUsageSample() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: locale}) + TokenUsageChart(c, usage, TokenUsageChartOptions{}) + }, 520, 420) + locale = ZhCN + tt.Frame() + if !tt.HasText("输入") || !tt.HasText("输出") { + t.Fatalf("stale legend: %q", tt.Texts()) + } +} + +func TestTokenUsageChartLargeDataBounded(t *testing.T) { + usage := make([]TokenUsage, 100000) + for i := range usage { + usage[i] = TokenUsage{Label: strconv.Itoa(i), Input: 1, Output: 2} + } + var cache *tokenUsageSeries + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := TokenUsageChart(c, usage, TokenUsageChartOptions{}) + cache = *ui.Local(r.Element, "series", func() *tokenUsageSeries { panic("missing series cache") }) + }, 520, 420) + if len(cache.labels) > 256 { + t.Fatalf("built %d categories", len(cache.labels)) + } + in, out := 0.0, 0.0 + for i := range cache.labels { + in += cache.series[0].Values[i] + out += cache.series[1].Values[i] + } + if in != 100000 || out != 200000 { + t.Fatalf("aggregated totals %v %v", in, out) + } + first := &cache.series[0].Values[0] + tt.Frame() + if &cache.series[0].Values[0] != first { + t.Fatal("unchanged data reaggregated") + } +} diff --git a/tool_registry_panel.go b/tool_registry_panel.go index cafa661..228df43 100644 --- a/tool_registry_panel.go +++ b/tool_registry_panel.go @@ -17,7 +17,7 @@ type ToolParam struct { Description string } -// AgentTool is a tool an agent may call; the caller keeps Enabled. +// AgentTool is a callable tool; the caller keeps Enabled. type AgentTool struct { Name string Description string @@ -28,36 +28,53 @@ type AgentTool struct { // ToolRegistryPanelOptions configure a ToolRegistryPanel. type ToolRegistryPanelOptions struct { Disabled bool + // Version changes when tool names are edited in place. + Version int } // ToolRegistryResult is a built ToolRegistryPanel and its events. type ToolRegistryResult struct { Element *ui.Element + List ListView toggled string on bool } -// Toggled returns the tool switched this frame and its new state. +// Toggled returns the tool switched this frame. 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. +type toolRegistryIndex struct { + src *AgentTool + n, version int + byName map[string]int +} + +// ToolRegistryPanel lists tools with switches and a schema preview. 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} + index := ui.Local(row, "index", func() toolRegistryIndex { return toolRegistryIndex{} }) + var first *AgentTool + if len(tools) > 0 { + first = &tools[0] + } + if index.byName == nil || index.src != first || index.n != len(tools) || index.version != opts.Version { + byName := make(map[string]int, len(tools)) + for i, t := range tools { + if _, exists := byName[t.Name]; t.Name == "" || exists { + panic("mujica: AgentTool needs a unique Name") + } + byName[t.Name] = i + } + *index = toolRegistryIndex{src: first, n: len(tools), version: opts.Version, byName: byName} + } row.Children(func() { ui.Column(c).Width(300).Shrink(0).AlignItems(ui.Stretch).Children(func() { - List(c, s, len(tools), ListOptions[string]{ + res.List = 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) { @@ -72,22 +89,18 @@ func ToolRegistryPanel(c *ui.Context, s *ListState[string], tools []AgentTool, o res.toggled, res.on = t.Name, on slog.Debug("mujica: tool registry toggle", "tool", t.Name, "on", on) } - }).Element.Grow(1) + }) + res.List.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 { + i, known := index.byName[name] + if !ok || !known { ui.Text(c, msg(c, "tool_registry_panel.choose")).TextColor(k.TextMuted) return } - toolSchema(c, *tool) + toolSchema(c, tools[i]) }) }) return res @@ -107,19 +120,22 @@ func toolSchema(c *ui.Context, t AgentTool) { 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) + ui.Column(c).Key(t.Name).Grow(1).MinHeight(0).Children(func() { + ui.List(c, nil, len(t.Params), func(i int) { + p := t.Params[i] + ui.Column(c).Gap(2).MinHeight(Density(c).ControlHeight()).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) } }) - if p.Description != "" { - ui.Text(c, p.Description).FontSize(small).TextColor(k.TextMuted) - } - }) - } + }).Grow(1).MinHeight(0) + }) } diff --git a/tool_registry_panel_test.go b/tool_registry_panel_test.go index 8e93561..7ac6eda 100644 --- a/tool_registry_panel_test.go +++ b/tool_registry_panel_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strconv" "testing" "github.com/egoist/mygo/ui" @@ -87,3 +88,60 @@ func TestToolRegistryPanelSnapshot(t *testing.T) { }) }, func(tt *ui.Tester) { _ = tt.Click("Search the web") }) } + +func TestToolRegistryPanelLargeDataKeyboardAndAncestorDisabled(t *testing.T) { + tools := make([]AgentTool, 100000) + for i := range tools { + tools[i] = AgentTool{Name: strconv.Itoa(i)} + } + var s ListState[string] + disabled := false + toggles := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r := ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{}) + r.Element.Height(300) + if _, _, ok := r.Toggled(); ok { + toggles++ + } + }) + }, 700, 400) + a, b := s.rows.Visible() + if b-a > 30 || len(tt.Texts()) > 150 { + t.Fatalf("built %d rows", b-a) + } + _ = tt.Click("0") + tt.Key(0, ui.KeyDown) + if id, _ := s.Selected(); id != "1" { + t.Fatalf("keyboard selected %q", id) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if toggles != 1 { + t.Fatalf("keyboard toggles %d", toggles) + } + disabled = true + tt.Key(0, ui.KeySpace) + if toggles != 1 { + t.Fatal("ancestor disabled toggled") + } +} + +func TestToolRegistryPanelLargeParameterPreview(t *testing.T) { + tools := []AgentTool{{Name: "Tool", Params: make([]ToolParam, 100000)}} + for i := range tools[0].Params { + tools[0].Params[i] = ToolParam{Name: "Param " + strconv.Itoa(i), Type: "string"} + } + var s ListState[string] + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolRegistryPanel(c, &s, tools, ToolRegistryPanelOptions{}).Element.Height(300) + }, 700, 400) + if err := tt.Click("Tool"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Param 0") || len(tt.Texts()) > 150 { + t.Fatalf("parameter preview built %d texts", len(tt.Texts())) + } +} diff --git a/trace_viewer.go b/trace_viewer.go index 7304c72..ca108bd 100644 --- a/trace_viewer.go +++ b/trace_viewer.go @@ -16,7 +16,7 @@ import ( // 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. +// TraceSpan records one call with trace-relative time offsets. type TraceSpan struct { ID string Parent string @@ -29,11 +29,12 @@ type TraceSpan struct { // 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 + Tree TreeTableState[string] + index traceIndex + src *TraceSpan + n int + built bool + version int } type traceIndex struct { @@ -47,19 +48,25 @@ type traceIndex struct { type TraceViewerOptions struct { Status DataStatus Error string + // Version changes when spans are edited in place. + Version int } -// TraceViewer shows a call tree with each span's time bar; the chosen span's attributes show below. +// TraceViewer shows a span tree with time bars and attributes. 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) { + if !s.built || s.src != first || s.n != len(spans) || s.version != opts.Version { + previous := s.index.byID s.index = traceBuild(spans) s.src, s.n, s.built = first, len(spans), true + s.version = opts.Version for _, id := range s.index.roots { - s.Tree.SetOpen(id, true) + if _, known := previous[id]; !known { + s.Tree.SetOpen(id, true) + } } slog.Debug("mujica: trace viewer index", "spans", len(spans), "roots", len(s.index.roots)) } @@ -112,6 +119,8 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra } var v DataTableView col := ui.Column(c).AlignItems(ui.Stretch).Gap(u * 2) + bounds := col.Bounds() + internal.Remeasure(c, col, bounds.W, bounds.H) col.Children(func() { v = TreeTable(c, &s.Tree, TreeTableOptions[string]{Columns: cols, Roots: ix.roots, Children: func(id string) []string { return ix.kids[id] }, @@ -122,33 +131,44 @@ func TraceViewer(c *ui.Context, s *TraceViewerState, spans []TraceSpan, opts Tra if _, known := ix.byID[id]; !ok || !known { return } - traceAttrs(c, span(id)) + height := bounds.H + if height == 0 { + _, height = c.Size() + } + traceAttrs(c, span(id), height) }) v.Element = col return v } // traceAttrs lists the chosen span's timing and attributes. -func traceAttrs(c *ui.Context, sp TraceSpan) { +func traceAttrs(c *ui.Context, sp TraceSpan, height float32) { 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) + ui.Row(c).Gap(u * 3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() { + ui.Text(c, sp.Name).Font(theme.SerifFont).FontWeight(600).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, msg(c, "trace_viewer.start")+" "+traceDuration(sp.Start)+" · "+msg(c, "trace_viewer.duration")+" "+traceDuration(sp.End-sp.Start)). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + }) row := func(key, val string) { - ui.Row(c).Gap(u * 3).Children(func() { + ui.Row(c).Gap(u * 3).MinHeight(Density(c).ControlHeight()).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) + if len(sp.Attrs) > 0 { + ui.Column(c).Key(sp.ID).Height(min(float32(len(sp.Attrs))*Density(c).ControlHeight(), max(Density(c).ControlHeight(), min(200, height/4)))).Children(func() { + ui.List(c, nil, len(sp.Attrs), func(i int) { + a := sp.Attrs[i] + row(a.Key, a.Value) + }).Grow(1).MinHeight(0) + }) } }) } -// traceBuild indexes spans by ID and parent; unknown parents make roots. +// traceBuild indexes spans; 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 { @@ -162,7 +182,7 @@ func traceBuild(spans []TraceSpan) traceIndex { ix.end = max(ix.end, sp.End) } for _, sp := range spans { - if _, ok := ix.byID[sp.Parent]; ok && sp.Parent != sp.ID { + if _, ok := ix.byID[sp.Parent]; ok { ix.kids[sp.Parent] = append(ix.kids[sp.Parent], sp.ID) } else { ix.roots = append(ix.roots, sp.ID) diff --git a/trace_viewer_test.go b/trace_viewer_test.go index f6b3de1..86449db 100644 --- a/trace_viewer_test.go +++ b/trace_viewer_test.go @@ -1,6 +1,7 @@ package mujica import ( + "strconv" "testing" "time" @@ -27,6 +28,7 @@ func TestTraceBuild(t *testing.T) { "dup": {{ID: "a"}, {ID: "a"}}, "time": {{ID: "a", Start: 2, End: 1}}, "cycle": {{ID: "a", Parent: "b"}, {ID: "b", Parent: "a"}}, + "self": {{ID: "a", Parent: "a"}}, } { func() { defer func() { @@ -83,3 +85,77 @@ func TestTraceViewerSnapshot(t *testing.T) { }) }) } + +func TestTraceViewerRefreshKeepsCollapsedRoot(t *testing.T) { + var s TraceViewerState + spans := traceSample() + tt, _ := motionFrames(func(c *ui.Context) { + TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(300) + }, 700, 400) + s.Tree.SetOpen("root", false) + tt.Frame() + spans = append([]TraceSpan(nil), spans...) + tt.Frame() + if s.Tree.IsOpen("root") || tt.HasText("llm.plan") { + t.Fatal("data refresh reopened a collapsed root") + } +} + +func TestTraceViewerLargeDataAndVersion(t *testing.T) { + spans := make([]TraceSpan, 100000) + for i := range spans { + spans[i] = TraceSpan{ID: strconv.Itoa(i), Name: "Span " + strconv.Itoa(i), End: time.Millisecond} + } + var s TraceViewerState + version := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TraceViewer(c, &s, spans, TraceViewerOptions{Version: version}).Element.Height(300) + }, 700, 400) + if len(s.index.byID) != 100000 || len(tt.Texts()) > 150 { + t.Fatalf("indexed %d texts %d", len(s.index.byID), len(tt.Texts())) + } + spans[0].End = 2 * time.Millisecond + version++ + tt.Frame() + if !tt.HasText("2.0 ms") { + t.Fatal("stale duration") + } +} + +func TestTraceViewerLargeAttributePreview(t *testing.T) { + spans := []TraceSpan{{ID: "root", Name: "Root", Attrs: make([]TraceAttr, 100000)}} + for i := range spans[0].Attrs { + spans[0].Attrs[i] = TraceAttr{Key: "Key " + strconv.Itoa(i), Value: "Value"} + } + var s TraceViewerState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(500) + }, 700, 600) + if err := tt.Click("Root"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Key 0") || len(tt.Texts()) > 150 { + t.Fatalf("attribute preview built %d texts", len(tt.Texts())) + } +} + +func TestTraceViewerAttributesKeepTreeVisible(t *testing.T) { + spans := []TraceSpan{{ID: "a", Name: "First span", Attrs: make([]TraceAttr, 20)}, {ID: "b", Name: "Second span"}} + for i := range spans[0].Attrs { + spans[0].Attrs[i] = TraceAttr{Key: strconv.Itoa(i), Value: "Value"} + } + var s TraceViewerState + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TraceViewer(c, &s, spans, TraceViewerOptions{}).Element.Height(260) + }, 700, 600) + if err := tt.Click("First span"); err != nil { + t.Fatal(err) + } + bounds, ok := tt.Find("Second span") + if !ok || bounds.H <= 0 || bounds.W <= 0 { + t.Fatalf("attributes hid tree row: %+v", bounds) + } +} diff --git a/transitions_test.go b/transitions_test.go deleted file mode 100644 index e42cfa3..0000000 --- a/transitions_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package mujica - -import ( - "testing" - "time" - - "github.com/egoist/mygo/ui" -) - -func TestPresenceEntersExitsAndRemoves(t *testing.T) { - for _, kind := range []PresenceKind{PresenceFade, PresenceSlide, PresenceCollapse} { - show := false - tt, n := motionFrames(func(c *ui.Context) { - ui.Column(c).Children(func() { - Presence(c, show, PresenceOptions{Kind: kind}, func() { ui.Text(c, "Notice") }) - ui.Text(c, "Below") - }) - }, 300, 120) - if tt.HasText("Notice") { - t.Fatal("hidden content built") - } - show = true - before := *n - tt.Frame() - if !tt.HasText("Notice") || *n-before < 3 { - t.Fatalf("kind %d: no entry motion (%d frames)", kind, *n-before) - } - time.Sleep(250 * time.Millisecond) - tt.Frame() - show = false - before = *n - tt.Frame() - if *n-before < 3 { - t.Fatalf("kind %d: no exit motion", kind) - } - time.Sleep(250 * time.Millisecond) - tt.Frame() - if tt.HasText("Notice") || motionBusy(tt, n) { - t.Fatalf("kind %d: not removed after exit", kind) - } - } -} - -func TestPresenceReducedMotionAndInvalid(t *testing.T) { - show := false - tt, n := motionFrames(func(c *ui.Context) { - ui.Column(c).Children(func() { Presence(c, show, PresenceOptions{Kind: PresenceSlide}, func() { ui.Text(c, "Notice") }) }) - }, 300, 120) - tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) - show = true - if motionBusy(tt, n) || !tt.HasText("Notice") { - t.Fatal("reduced motion animated") - } - defer func() { - if recover() == nil { - t.Fatal("unknown kind did not panic") - } - }() - ui.Render(func(c *ui.Context) { Use(c, Settings{}); Presence(c, true, PresenceOptions{Kind: 9}, nil) }, 100, 50, 1) -} - -func TestStaggerRevealsInTurnOnce(t *testing.T) { - count := 5 - tt, n := motionFrames(func(c *ui.Context) { - ui.Column(c).Children(func() { - Stagger(c, count, StaggerOptions{Interval: 50 * time.Millisecond}, func(i int) { ui.Textf(c, "Row %d", i) }) - }) - }, 300, 240) - if !tt.HasText("Row 0") || tt.HasText("Row 4") { - t.Fatalf("first frame: %q", tt.Texts()) - } - time.Sleep(120 * time.Millisecond) - tt.Frame() - if !tt.HasText("Row 2") || tt.HasText("Row 4") { - t.Fatalf("after 120 ms: %q", tt.Texts()) - } - time.Sleep(300 * time.Millisecond) - tt.Frame() - time.Sleep(250 * time.Millisecond) - tt.Frame() - if !tt.HasText("Row 4") || motionBusy(tt, n) { - t.Fatalf("did not finish and settle: %q", tt.Texts()) - } - count = 6 - tt.Frame() - if !tt.HasText("Row 5") { - t.Fatal("item added after the sequence waited") - } -} - -func TestStaggerReducedMotionAndInvalid(t *testing.T) { - tt := ui.NewTester(func(c *ui.Context) { - Use(c, Settings{}) - if c.Preferences().ReduceMotion { - Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Row %d", i) }) - } - }, 300, 200) - tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) - if !tt.HasText("Row 2") { - t.Fatalf("reduced motion staggered: %q", tt.Texts()) - } - defer func() { - if recover() == nil { - t.Fatal("negative n did not panic") - } - }() - ui.Render(func(c *ui.Context) { Use(c, Settings{}); Stagger(c, -1, StaggerOptions{}, nil) }, 100, 50, 1) -} - -func TestReorderTransitionSlides(t *testing.T) { - keys := []string{"a", "b", "c"} - tt, n := motionFrames(func(c *ui.Context) { - ReorderTransition(c, keys, ReorderTransitionOptions{Gap: 4}, func(k string) { - ui.Text(c, "Item "+k).Padding(8) - }) - }, 300, 200) - ra, _ := tt.Find("Item a") - keys = []string{"c", "b", "a"} - before := *n - tt.Frame() - if *n-before < 3 { - t.Fatal("reorder did not animate") - } - mid, _ := tt.Find("Item a") - time.Sleep(260 * time.Millisecond) - tt.Frame() - end, _ := tt.Find("Item a") - if !(mid.Y > ra.Y && mid.Y < end.Y) || motionBusy(tt, n) { - t.Fatalf("a moved %v → %v → %v", ra.Y, mid.Y, end.Y) - } - defer func() { - if recover() == nil { - t.Fatal("duplicate key did not panic") - } - }() - ui.Render(func(c *ui.Context) { - Use(c, Settings{}) - ReorderTransition(c, []int{1, 1}, ReorderTransitionOptions{}, func(int) {}) - }, 100, 50, 1) -} - -func TestLayoutTransitionResizes(t *testing.T) { - wide := false - var w float32 - tt, n := motionFrames(func(c *ui.Context) { - ui.Column(c).AlignItems(ui.Start).Children(func() { - box := LayoutTransition(c, LayoutTransitionOptions{Clip: true}, func() { - width := float32(100) - if wide { - width = 300 - } - ui.Box(c).Size(width, 40) - }) - w = box.Bounds().W - }) - }, 400, 120) - wide = true - before := *n - tt.Frame() - if *n-before < 3 { - t.Fatal("resize did not animate") - } - time.Sleep(260 * time.Millisecond) - tt.Frame() - if motionBusy(tt, n) || w != 300 { - t.Fatalf("settled width %v", w) - } - defer func() { - if recover() == nil { - t.Fatal("negative duration did not panic") - } - }() - ui.Render(func(c *ui.Context) { Use(c, Settings{}); LayoutTransition(c, LayoutTransitionOptions{Duration: -1}, nil) }, 100, 50, 1) -} - -func TestPresenceSnapshot(t *testing.T) { - snapshot(t, "presence", 300, 100, func(c *ui.Context) { - ui.Column(c).Padding(16).Children(func() { - Presence(c, true, PresenceOptions{}, func() { ui.Text(c, "The doors open.") }) - }) - }) -} - -func TestStaggerSnapshot(t *testing.T) { - snapshot(t, "stagger", 300, 140, func(c *ui.Context) { - ui.Column(c).Padding(16).Children(func() { - Stagger(c, 3, StaggerOptions{}, func(i int) { ui.Textf(c, "Guest %d arrives", i+1) }) - }) - }) -} - -func TestReorderTransitionSnapshot(t *testing.T) { - snapshot(t, "reorder_transition", 300, 140, func(c *ui.Context) { - ui.Column(c).Padding(16).Children(func() { - ReorderTransition(c, []string{"Sakiko", "Uika", "Mutsumi"}, ReorderTransitionOptions{Gap: 4}, func(k string) { - ui.Text(c, k).Padding(4, 8).Background(Tokens(c).Surface).Radius(4) - }) - }) - }) -} - -func TestLayoutTransitionSnapshot(t *testing.T) { - snapshot(t, "layout_transition", 300, 100, func(c *ui.Context) { - ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() { - LayoutTransition(c, LayoutTransitionOptions{}, func() { - ui.Text(c, "A panel that grows").Padding(12).Background(Tokens(c).Surface).Radius(4) - }) - }) - }) -} diff --git a/typewriter.go b/typewriter.go index b1bc810..a95d7a9 100644 --- a/typewriter.go +++ b/typewriter.go @@ -2,6 +2,7 @@ package mujica import ( "log/slog" + "math" "strings" "time" @@ -12,19 +13,10 @@ import ( 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 adds a Skip button. 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 @@ -33,26 +25,31 @@ type typewriterState struct { 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 { +// Typewriter reveals text one character at a time. +func Typewriter(c *ui.Context, text string, opts TypewriterOptions) ChoiceResult { speed := opts.Speed if speed == 0 { speed = 40 } - if speed < 0 { - panic("mujica: Typewriter Speed is negative") + if math.IsNaN(speed) || math.IsInf(speed, 0) || speed < float64(time.Second)/float64(math.MaxInt64) || speed > float64(time.Second) { + panic("mujica: Typewriter Speed must give a finite positive interval") + } + step := time.Duration(float64(time.Second) / speed) + if step <= 0 { + panic("mujica: Typewriter Speed interval exceeds time.Duration") } col := ui.Column(c).AlignItems(ui.Start).Gap(Density(c).Unit() * 2).Role(ui.RoleGroup).Label(text) - res := TypewriterResult{Element: col} + res := ChoiceResult{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 + s.last = c.Now() slog.Debug("mujica: typewriter restart") } if len(runes) > len(s.text) && s.done { - // New text continues from where it stood, at the typing speed. + // Appended text continues at the typing speed. s.done, s.skipped, s.last = false, false, c.Now() } s.text = runes @@ -60,13 +57,12 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe 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)) + col.DrawOver(func(*ui.Painter, ui.Rect) { c.After(s.last.Add(step).Sub(c.Now())) }) } } else { s.last = now @@ -74,14 +70,15 @@ func Typewriter(c *ui.Context, text string, opts TypewriterOptions) TypewriterRe col.Children(func() { ui.Text(c, string(runes[:s.shown])) if opts.Skippable && s.shown < len(runes) { - if Button(c, msg(c, "typewriter.skip"), ButtonOptions{Variant: Ghost}).Clicked() { + b := Button(c, msg(c, "typewriter.skip"), ButtonOptions{Variant: Ghost}) + if b.Clicked() && !b.IsDisabled() { 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 + s.done, res.changed = true, true } return res } diff --git a/typewriter_test.go b/typewriter_test.go index 23f2103..e2ffd04 100644 --- a/typewriter_test.go +++ b/typewriter_test.go @@ -1,6 +1,7 @@ package mujica import ( + "math" "testing" "time" @@ -11,7 +12,7 @@ func TestTypewriterRevealsFinishesAndSkips(t *testing.T) { text := "The court assembles at dusk." finished := 0 tt, n := motionFrames(func(c *ui.Context) { - if Typewriter(c, text, TypewriterOptions{Speed: 100, Skippable: true}).Finished() { + if Typewriter(c, text, TypewriterOptions{Speed: 100, Skippable: true}).Changed() { finished++ } }, 400, 120) @@ -51,13 +52,13 @@ func TestTypewriterReducedMotionAndInvalid(t *testing.T) { finished := false tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - finished = Typewriter(c, "All at once", TypewriterOptions{}).Finished() || finished + finished = Typewriter(c, "All at once", TypewriterOptions{}).Changed() || finished }, 300, 80) tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) _ = tt tt2 := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - finished = Typewriter(c, "Shown now", TypewriterOptions{}).Finished() || finished + finished = Typewriter(c, "Shown now", TypewriterOptions{}).Changed() || finished }, 300, 80) tt2.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) if motionCount(tt2, "Shown now") != 2 || !finished { @@ -78,3 +79,51 @@ func TestTypewriterSnapshot(t *testing.T) { }) }) } + +func TestTypewriterReplacementRestartsClock(t *testing.T) { + text := "A long opening message" + tt, _ := motionFrames(func(c *ui.Context) { + Typewriter(c, text, TypewriterOptions{Speed: 10}) + }, 300, 100) + time.Sleep(220 * time.Millisecond) + text = "New letter" + tt.Frame() + if motionCount(tt, "Ne") > 0 { + t.Fatalf("replacement consumed old elapsed time: %q", tt.Texts()) + } +} + +func TestTypewriterInvalidSpeedBoundary(t *testing.T) { + for _, speed := range []float64{nanValue(), math.Inf(1), 2e9} { + func() { + defer func() { + if recover() == nil { + t.Errorf("invalid speed %v accepted", speed) + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Typewriter(c, "text", TypewriterOptions{Speed: speed}) + }, 200, 100, 1) + }() + } +} + +func TestTypewriterMinimumIntervalBoundary(t *testing.T) { + minimum := float64(time.Second) / float64(math.MaxInt64) + tt, n := motionFrames(func(c *ui.Context) { + Typewriter(c, "letter", TypewriterOptions{Speed: minimum}) + }, 200, 100) + if motionCount(tt, "letter") != 1 || motionBusy(tt, n) { + t.Fatal("slow typing should wait with partial text") + } + defer func() { + if recover() == nil { + t.Fatal("unrepresentable interval accepted") + } + }() + ui.Render(func(c *ui.Context) { + Use(c, Settings{}) + Typewriter(c, "letter", TypewriterOptions{Speed: math.Nextafter(minimum, 0)}) + }, 200, 100, 1) +} diff --git a/wave_loader.go b/wave_loader.go index f82b161..13eb3a0 100644 --- a/wave_loader.go +++ b/wave_loader.go @@ -10,7 +10,7 @@ import ( // 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) { + return loaderMark(c, opts, "wave_loader", 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 diff --git a/wave_loader_test.go b/wave_loader_test.go new file mode 100644 index 0000000..a4ae0f0 --- /dev/null +++ b/wave_loader_test.go @@ -0,0 +1,7 @@ +package mujica + +import "testing" + +func TestWaveLoaderSnapshot(t *testing.T) { loaderSnapshot(t, "wave_loader", WaveLoader) } + +func TestWaveLoaderBehavior(t *testing.T) { testLoaderBehavior(t, WaveLoader) }