WIP components (as built)
This commit is contained in:
71 files changed
+3241
-468
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
+8
-6
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+19
-10
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}()
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -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() })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 取消。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只报告中断请求,取消运行由调用方完成。
|
||||
@@ -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 聚焦图表,←/→ 在柱或点之间移动,读数显示并朗读。
|
||||
|
||||
## 限制
|
||||
|
||||
- 折线模式的横轴为序号,类别名只在柱状模式显示。
|
||||
@@ -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:↑/↓ 选择行,列标题可用键盘排序。
|
||||
|
||||
## 限制
|
||||
|
||||
- 金额以浮点汇总并按小数位显示,不做货币舍入规则。
|
||||
@@ -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 虚拟化);属性面板也按可见行虚拟化,独立滚动。
|
||||
@@ -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。
|
||||
@@ -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 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
- 参数只做平铺预览,不展开嵌套对象。
|
||||
@@ -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`。
|
||||
|
||||
## 状态
|
||||
|
||||
- 只在绘制时请求下一帧:不再构建或被隐藏后停止重绘。
|
||||
- 减少动画:标记静止,并始终显示文字说明。
|
||||
- 无弹跳、无发光。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无:不获取焦点,以进度角色朗读。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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。
|
||||
|
||||
## 状态
|
||||
|
||||
- 条高吸附像素;只在绘制时动画。
|
||||
- 减少动画:静止并显示文字。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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,无光晕。
|
||||
- 减少动画:静止并显示文字。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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。
|
||||
|
||||
## 状态
|
||||
|
||||
- 移动的菱形坐标吸附像素。
|
||||
- 减少动画:菱形停在顶端并显示文字。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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。
|
||||
|
||||
## 状态
|
||||
|
||||
- 路径点吸附像素。
|
||||
- 减少动画:静止的波形并显示文字。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 色带,位置吸附像素,只在绘制时动画。
|
||||
- 减少动画:静态填充。
|
||||
- 加载结束后不再重绘。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只用于加载中,不作装饰。
|
||||
@@ -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 须为有限值。首次显示直接为目标值。
|
||||
|
||||
## 状态
|
||||
|
||||
- 缓出过渡,过渡中途再变化时从当前显示值继续。
|
||||
- 等宽数字防止抖动;朗读最终值。
|
||||
- 减少动画:直接显示新值。过渡结束或屏外隐藏后停止重绘;恢复动画设置时保持已显示的最终值。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 跳过。
|
||||
|
||||
## 限制
|
||||
|
||||
- 文本被替换为不以旧文本开头的内容时从头开始。
|
||||
@@ -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,再回落;只在绘制时动画。
|
||||
- 以状态角色朗读文字。
|
||||
- 减少动画或未激活:静态文字。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 扫光时文字保持单行。
|
||||
@@ -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 外淡入。
|
||||
- 序列结束后新增的项照常进场,不再等待。
|
||||
- 减少动画:一次全部显示。按定时请求帧。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 尚未出现的项不占位,容器随之增长。
|
||||
@@ -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)。
|
||||
@@ -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 才会同步移动。
|
||||
@@ -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)。
|
||||
- 父元素在上一帧不存在时,内容随父元素一起出现而不进场。
|
||||
@@ -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% 透明度淡出一次,结束后停止重绘。
|
||||
- 方向箭头与朗读的“上升/下降”在变化后一直保留,不只靠颜色。
|
||||
- 减少动画:不闪烁,只显示静态箭头。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 无。
|
||||
|
||||
## 限制
|
||||
|
||||
- 开启减少动画会结束当前高亮;恢复动画时保持静态方向标记。
|
||||
+7
-7
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
+36
-16
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+34
-44
@@ -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) }
|
||||
@@ -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", "上升"},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
+7
-3
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
+29
-6
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
+27
-12
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+49
-33
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
+39
-19
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+17
-20
@@ -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
|
||||
}
|
||||
+52
-3
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
Reference in new issue
Block a user