Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+15 -10
View File
@@ -1,6 +1,6 @@
# AGENTS.md
MujicaUI: court-gothic components for MyGo native UI. Go library, package `mujica`, module `mujicaui`.
MujicaUI: court-gothic components for MyGo native UI. Go library, module `github.com/ZacharyZhang-NY/MujicaUI`, one package per component category.
Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/ext-*.md` (101–500).
Planned: once 001–500 land, the root package splits into category sub-packages (TASKS T22). Until then everything stays in the root package.
@@ -18,17 +18,22 @@ Read MyGo source before using an API: `go env GOMODCACHE`/github.com/egoist/mygo
## Layout
```text
*.go one component per file, root package mujica
*_test.go tests beside the component
messages_<group>.go built-in copy (en-US, zh-CN) via addMessages
core/ Use, Tokens, Density, Motion, FontSize, Msg; shared result types and helpers;
messages_<group>.go built-in copy (en-US, zh-CN) via addMessages; docs tests
<category>/ one component per file, package = folder name:
layout display input datetime navigation data overlay feedback chart
finance chat agent git code media project files messaging devtools account
<category>/*_test.go tests beside the component; example_<group>_test.go compiled doc examples
theme/ tokens, density, type, motion, contrast
icons/ embedded Lucide icons: icons.Must("check")
internal/ focus ring, ornaments
internal/ focus ring, ornaments; internal/snaptest runs snapshot goldens
examples/gallery/ native Gallery; pages_<group>.go per PRD 4.x group
docs/components/ NNN-name.md, one page per component
testdata/snapshots/ <os>/<name>-<light|dark>.png
testdata/snapshots/ <os>/<name>-<light|dark>.png, shared by every package
```
Gallery categories map to packages: Inputs, Choices, Forms, Essentials -> input; Date & Time, Calendar -> datetime; Feedback, Agent Ops & Motion -> feedback; Charts I, Charts II, Market Charts -> chart; Trading, Markets -> finance; Chat Messages, Chat Input, Chat Sessions -> chat; Terminal & Code -> code; Projects -> project; Files & Collaboration -> files; Messaging & Mail -> messaging; Dashboards & Dev Tools -> devtools; Settings & Account -> account. Imports point down only: core imports no category; a piece two categories need moves to core or internal.
## Component rules
- Signature: `func Name(c *ui.Context, value *T, opts NameOptions) *ui.Element`. Composites may return a named struct.
@@ -36,15 +41,15 @@ testdata/snapshots/ <os>/<name>-<light|dark>.png
- Shared helpers: `iconAction` (borderless icon button), `overlayStyle(c, panel, overlayEnter(c, panel))` (popup surface and entry fade), `internal.RuleDiamond` / `internal.Frame` (ornaments), `colorFade` (state color transition).
- Zero `Options` gives the default look. No `map[string]any` config.
- Explicit font sizes: `FontSize(c, theme.CaptionSize)`; it applies the desktop text scale.
- Colors: `k := mujica.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
- Colors: `k := core.Tokens(c)`. Metrics: `theme.*` consts and `Density(c).Unit()` / `.ControlHeight()`.
- Build on MyGo Bases (`ui.ButtonBase`, `ui.CheckboxBase`, `ui.SelectBase`, ...) or MyGo widgets restyled through the theme `Use` sets. Never reach into MyGo internals.
- Focus: `internal.Ring(e, k.Focus, radius)` for the 2 DIP ring with 2 DIP gap.
- State marks (checked, selected, progress) use `OnAccent` on `Accent`, or `AccentText`, never `Accent` alone on dark surfaces (2.44:1).
- Ornament color only for dividers, corners, panel titles. No gradients, glow, glass or texture.
- Animated paths: pass coordinates through `internal.Snap(p, v)`. MyGo caches path masks by quarter-pixel keys (ui/path.go); unsnapped motion reuses near-miss masks and breaks snapshots.
- Durations: `mujica.Motion(c, theme.StateDuration)`; popups use `theme.PopupDuration`.
- Durations: `core.Motion(c, theme.StateDuration)`; popups use `theme.PopupDuration`.
- Disabled blocks input before handling it (`Disabled(true)`); MyGo dims disabled elements, do not add opacity.
- Copy shown by a component comes from `msg(c, key)`; business copy comes from the caller.
- Copy shown by a component comes from `core.Msg(c, key)`; business copy comes from the caller.
- No cross-frame `*ui.Element` caching, no goroutines touching elements.
- Fail fast: panic on programmer errors (bad options); never swallow errors.
- Log state transitions in composites with `slog.Debug("mujica: <component> <event>", ...)`. Never log passwords.
@@ -53,7 +58,7 @@ testdata/snapshots/ <os>/<name>-<light|dark>.png
## Tests
- `ui.NewTester` drives click, keys, typing. Assert values, not existence.
- Every component: one `snapshot(t, name, w, h, view)` call (light and dark). Refresh with `go test -run <Name> -update .`
- Every component: one `snapshot(t, name, w, h, view)` call (light and dark; each package wraps `internal/snaptest`). Refresh with `go test -run <Name> -update ./<category>`
- Run `go vet ./... && go test ./...` before reporting done.
- Each snapshot test renders in a fresh child process (snapshot_test.go), because MyGo's path-mask cache is process-wide. Order and neighbours never change pixels; `-run <Name> -update` is safe.
+11 -12
View File
@@ -6,10 +6,8 @@
## 安装
模块路径暂为 `mujicaui`,尚未发布到代码托管地址。本地使用:
```sh
go mod edit -require=mujicaui@v0.0.0 -replace=mujicaui=/path/to/MujicaUI
go get github.com/ZacharyZhang-NY/MujicaUI
```
要求 Go 1.27.1 与 MyGo v0.2.10(见 [兼容表](docs/compat.md))。
@@ -27,16 +25,17 @@ import (
"github.com/egoist/mygo"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
type app struct{ name string }
func (a *app) view(c *ui.Context) {
mujica.Use(c, mujica.Settings{Locale: mujica.ZhCN})
core.Use(c, core.Settings{Locale: core.ZhCN})
ui.Column(c).Fill().Center().Gap(12).Children(func() {
mujica.TextInput(c, &a.name, mujica.TextInputOptions{Placeholder: "姓名"})
if mujica.Button(c, "觐见", mujica.ButtonOptions{}).Clicked() {
input.TextInput(c, &a.name, input.TextInputOptions{Placeholder: "姓名"})
if input.Button(c, "觐见", input.ButtonOptions{}).Clicked() {
log.Println("hello", a.name)
}
})
@@ -55,15 +54,15 @@ func main() {
## 主题
每帧开头调用 `mujica.Use(c, settings)`。`Settings` 决定模式(`System`、`Light`、`Dark`)、密度(`theme.Compact`、`theme.Default`、`theme.Comfortable`)、语言与文案覆盖。每个窗口各自设置,切换不重置业务状态。
每帧开头调用 `core.Use(c, settings)`。`Settings` 决定模式(`System`、`Light`、`Dark`)、密度(`theme.Compact`、`theme.Default`、`theme.Comfortable`)、语言与文案覆盖。每个窗口各自设置,切换不重置业务状态。
```go
mujica.Use(c, mujica.Settings{
Mode: mujica.Dark,
core.Use(c, core.Settings{
Mode: core.Dark,
Density: theme.Compact,
Locale: mujica.ZhCN,
Locale: core.ZhCN,
})
k := mujica.Tokens(c) // 当前窗口的颜色
k := core.Tokens(c) // 当前窗口的颜色
```
覆盖顺序:组件参数,窗口 `Settings`,库默认值。系统高对比度、文本缩放、减少动画始终生效。
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -7,9 +7,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AccentColorPickerOptions configure an AccentColorPicker.
@@ -31,7 +33,7 @@ func accentPickerReadable(k theme.Tokens, accent ui.Color) bool {
}
// AccentColorPicker selects an accent and marks insufficient contrast.
func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOptions) ChoiceResult {
func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOptions) core.ChoiceResult {
cands := opts.Candidates
if cands == nil {
cands = accentPickerColors
@@ -39,13 +41,13 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
if len(cands) == 0 {
panic("mujica: AccentColorPicker needs candidates")
}
k := Tokens(c)
d := Density(c)
var r ChoiceResult
k := core.Tokens(c)
d := core.Density(c)
var r core.ChoiceResult
r.Element = ui.Column(c).Gap(d.Unit() * 2)
r.Element.Children(func() {
low := false
ui.Row(c).Gap(d.Unit() * 2).Wrap().Role(ui.RoleRadioGroup).Label(msg(c, "accent_color_picker.label")).
ui.Row(c).Gap(d.Unit() * 2).Wrap().Role(ui.RoleRadioGroup).Label(core.Msg(c, "accent_color_picker.label")).
FocusGroup(ui.Horizontal).Disabled(opts.Disabled).Children(func() {
selected := color
if r.Element.IsDisabled() || opts.Disabled {
@@ -55,13 +57,13 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
for _, col := range cands {
ok := accentPickerReadable(k, col)
low = low || !ok
hex := strings.ToUpper(colorPickerHex(col))
hex := strings.ToUpper(input.ColorPickerHex(col))
sw := ui.RadioBase(c, selected, col).Size(36, 36).Center().Radius(theme.CardRadius).Label(hex)
if !ok {
sw.Description(msg(c, "accent_color_picker.low"))
sw.Description(core.Msg(c, "accent_color_picker.low"))
}
if sw.Changed() && !sw.IsDisabled() {
r.changed = true
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: accent_color_picker chose", "color", hex)
}
internal.Ring(sw, k.Focus, theme.CardRadius)
@@ -86,12 +88,12 @@ func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOpt
if low {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12)
ui.Text(c, msg(c, "accent_color_picker.low_note")).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, core.Msg(c, "accent_color_picker.low_note")).FontSize(core.FontSize(c, theme.CaptionSize))
})
}
if !accentPickerReadable(k, *color) {
ui.Text(c, fmt.Sprintf(msg(c, "accent_color_picker.chosen_low"), strings.ToUpper(colorPickerHex(*color)))).
TextColor(k.Warning).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
ui.Text(c, fmt.Sprintf(core.Msg(c, "accent_color_picker.chosen_low"), strings.ToUpper(input.ColorPickerHex(*color)))).
TextColor(k.Warning).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
}
})
return r
@@ -1,11 +1,11 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
func TestAccentColorPickerMarksLowContrast(t *testing.T) {
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,9 +6,14 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Account is a signed-in account a user can switch to.
@@ -62,8 +67,8 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
accountSwitcherLarge(c, b, open, current, accounts, &r)
return r
}
popupMenuOpen(c, b, open, func(m *PopupMenu) {
m.Group(msg(c, "account_switcher.accounts"), func() {
overlay.PopupMenuOpen(c, b, open, func(m *overlay.PopupMenu) {
m.Group(core.Msg(c, "account_switcher.accounts"), func() {
for _, a := range accounts {
on := a.ID == *current
label := a.Name
@@ -71,7 +76,7 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
label += " · " + a.Email
}
ui.Column(c).Key(a.ID).Shrink(0).Children(func() {
if m.Check(label, &on, PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() {
if m.Check(label, &on, overlay.PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() {
*current, r.changed = a.ID, true
slog.Debug("mujica: account_switcher switched")
}
@@ -79,7 +84,7 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
}
})
m.Separator()
if m.Item(msg(c, "account_switcher.add"), PopupMenuItemOptions{}) && !b.IsDisabled() {
if m.Item(core.Msg(c, "account_switcher.add"), overlay.PopupMenuItemOptions{}) && !b.IsDisabled() {
r.add = true
slog.Debug("mujica: account_switcher add")
}
@@ -88,13 +93,13 @@ func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts Ac
}
func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *string, accounts []Account, r *AccountSwitcherResult) {
Popover(c, b, open, PopoverOptions{Label: msg(c, "account_switcher.accounts"), Width: 320}, func() {
overlay.Popover(c, b, open, overlay.PopoverOptions{Label: core.Msg(c, "account_switcher.accounts"), Width: 320}, func() {
cursor := ui.Local(b, "cursor", func() int { return -1 })
rows := ui.Local(b, "list", func() ui.ListState { return ui.ListState{} })
rows.Key = func(i int) any { return accounts[i].ID }
rows.Label = func(i int) string { return accounts[i].Name }
rows.Selected = cursor
restore := dataSelectTheme(c, 0)
restore := data.DataSelectTheme(c, 0)
list := ui.List(c, rows, len(accounts), func(i int) {
a := accounts[i]
row := workspaceRow(c, Workspace{ID: a.ID, Name: a.Name, Detail: a.Email}, a.ID == *current, i == *cursor)
@@ -106,7 +111,7 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
}
}).Height(280).MinHeight(0).AutoFocus()
restore()
internal.Ring(list, Tokens(c).Focus, theme.ControlRadius)
internal.Ring(list, core.Tokens(c).Focus, theme.ControlRadius)
if list.Changed() && !b.IsDisabled() {
list.Focus()
}
@@ -114,7 +119,7 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
id := accounts[*cursor].ID
r.changed, *current, *open = id != *current, id, false
}
if Button(c, msg(c, "account_switcher.add"), ButtonOptions{Variant: Ghost}).Clicked() && !b.IsDisabled() {
if input.Button(c, core.Msg(c, "account_switcher.add"), input.ButtonOptions{Variant: input.Ghost}).Clicked() && !b.IsDisabled() {
r.add, *open = true, false
}
})
@@ -125,10 +130,10 @@ func accountSwitcherLarge(c *ui.Context, b *ui.Element, open *bool, current *str
// accountTrigger displays an avatar, name and detail.
func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disabled bool) *ui.Element {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
b := ui.ButtonBase(c).Role(ui.RoleMenuButton).MinHeight(d.ControlHeight()+d.Unit()*2).Padding(d.Unit(), d.Unit()*3, d.Unit(), d.Unit()*2).
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Justify(ui.Start).Disabled(disabled).Label(name).Transition(colorFade)
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Justify(ui.Start).Disabled(disabled).Label(name).Transition(core.ColorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
@@ -137,11 +142,11 @@ func accountTrigger(c *ui.Context, name, detail string, avatar *ui.Bitmap, disab
b.Background(k.SurfaceHover)
}
b.Children(func() {
Avatar(c, name, AvatarOptions{Image: avatar, Size: 28})
display.Avatar(c, name, display.AvatarOptions{Image: avatar, Size: 28})
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
ui.Text(c, name).FontWeight(600).SingleLine().Ellipsis("…")
if detail != "" {
ui.Text(c, detail).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…")
ui.Text(c, detail).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…")
}
})
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -9,8 +9,12 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// APIKey contains public metadata and the visible prefix.
@@ -65,8 +69,8 @@ func (r ApiKeyManagerResult) Revoked() (string, bool) { return r.revoke, r.revok
// ApiKeyManager masks stored keys and confirms revocation.
func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts ApiKeyManagerOptions) ApiKeyManagerResult {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
var r ApiKeyManagerResult
r.Element = ui.Column(c).Gap(d.Unit() * 3).MinWidth(460).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
@@ -89,12 +93,12 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api
byID := s.index
r.Element.Children(func() {
ui.Row(c).Children(func() {
if Button(c, msg(c, "api_key_manager.create"), ButtonOptions{Icon: icons.Must("plus"), Disabled: disabled}).Clicked() && !disabled {
if input.Button(c, core.Msg(c, "api_key_manager.create"), input.ButtonOptions{Icon: icons.Must("plus"), Disabled: disabled}).Clicked() && !disabled {
s.creating, s.name, s.err = true, "", ""
}
})
if len(keys) == 0 {
EmptyState(c, EmptyStateOptions{Title: msg(c, "api_key_manager.empty")})
feedback.EmptyState(c, feedback.EmptyStateOptions{Title: core.Msg(c, "api_key_manager.empty")})
}
accountRows(c, len(keys), func(i int) any { return keys[i].ID }, func(i int) {
key := keys[i]
@@ -103,10 +107,10 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
ui.Text(c, key.Name).FontWeight(600).SingleLine()
ui.Text(c, apiKeyMasked(key.Prefix)).Font(theme.MonoFont).TextColor(k.TextMuted).SingleLine()
ui.Text(c, apiKeyDates(c, key)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, apiKeyDates(c, key)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
})
b := Button(c, msg(c, "api_key_manager.revoke"), ButtonOptions{Variant: Secondary, Disabled: disabled}).
Label(fmt.Sprintf(msg(c, "api_key_manager.revoke_named"), key.Name))
b := input.Button(c, core.Msg(c, "api_key_manager.revoke"), input.ButtonOptions{Variant: input.Secondary, Disabled: disabled}).
Label(fmt.Sprintf(core.Msg(c, "api_key_manager.revoke_named"), key.Name))
if b.Clicked() && !disabled {
s.revoking = key.ID
}
@@ -119,8 +123,8 @@ func ApiKeyManager(c *ui.Context, s *ApiKeyManagerState, keys []APIKey, opts Api
}
victim, exists := byID[s.revoking]
open := s.revoking != "" && exists
res := AlertDialog(c, &open, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "api_key_manager.revoke_title"), victim.Name),
Description: msg(c, "api_key_manager.revoke_text"), ConfirmLabel: msg(c, "api_key_manager.revoke")})
res := overlay.AlertDialog(c, &open, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "api_key_manager.revoke_title"), victim.Name),
Description: core.Msg(c, "api_key_manager.revoke_text"), ConfirmLabel: core.Msg(c, "api_key_manager.revoke")})
if res.Confirmed && !disabled {
r.revoke = victim.ID
slog.Debug("mujica: api_key_manager revoke")
@@ -136,36 +140,36 @@ func apiKeyMasked(prefix string) string { return prefix + strings.Repeat("•",
// apiKeyDates says when a key was made and last used.
func apiKeyDates(c *ui.Context, key APIKey) string {
used := msg(c, "api_key_manager.never")
used := core.Msg(c, "api_key_manager.never")
if !key.LastUsed.IsZero() {
used = key.LastUsed.Format("2006-01-02")
}
return fmt.Sprintf(msg(c, "api_key_manager.dates"), key.Created.Format("2006-01-02"), used)
return fmt.Sprintf(core.Msg(c, "api_key_manager.dates"), key.Created.Format("2006-01-02"), used)
}
// apiKeyCreateDialog collects a name and reveals the generated secret.
func apiKeyCreateDialog(c *ui.Context, s *ApiKeyManagerState, disabled bool) (string, bool) {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
open := !disabled && (s.creating || s.pending || s.secret != "")
was := open
asked := false
title := msg(c, "api_key_manager.create_title")
title := core.Msg(c, "api_key_manager.create_title")
if s.secret != "" {
title = msg(c, "api_key_manager.secret_title")
title = core.Msg(c, "api_key_manager.secret_title")
}
Dialog(c, &open, DialogOptions{Title: title, KeepOnBackdrop: true, KeepOnEscape: s.pending, HideClose: s.pending, Actions: func() {
overlay.Dialog(c, &open, overlay.DialogOptions{Title: title, KeepOnBackdrop: true, KeepOnEscape: s.pending, HideClose: s.pending, Actions: func() {
switch {
case s.secret != "":
if Button(c, msg(c, "api_key_manager.done"), ButtonOptions{}).Clicked() {
if input.Button(c, core.Msg(c, "api_key_manager.done"), input.ButtonOptions{}).Clicked() {
open = false
}
default:
if Button(c, msg(c, "api_key_manager.cancel"), ButtonOptions{Variant: Secondary, Disabled: s.pending}).Clicked() {
if input.Button(c, core.Msg(c, "api_key_manager.cancel"), input.ButtonOptions{Variant: input.Secondary, Disabled: s.pending}).Clicked() {
open = false
}
ok := strings.TrimSpace(s.name) != ""
if Button(c, msg(c, "api_key_manager.create"), ButtonOptions{Loading: s.pending, Disabled: !ok}).Clicked() && ok && !s.pending {
if input.Button(c, core.Msg(c, "api_key_manager.create"), input.ButtonOptions{Loading: s.pending, Disabled: !ok}).Clicked() && ok && !s.pending {
asked = true
}
}
@@ -173,19 +177,19 @@ func apiKeyCreateDialog(c *ui.Context, s *ApiKeyManagerState, disabled bool) (st
if s.secret != "" {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Padding(d.Unit() * 3).Radius(theme.ControlRadius).Background(k.Warning.Alpha(0.12)).Children(func() {
ui.Icon(c, icons.Must("triangle-alert")).Size(16, 16).TextColor(k.Warning).Shrink(0)
ui.Text(c, msg(c, "api_key_manager.once")).Grow(1)
ui.Text(c, core.Msg(c, "api_key_manager.once")).Grow(1)
})
ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Children(func() {
ui.Text(c, s.secret).Font(theme.MonoFont).Selectable().Label(msg(c, "api_key_manager.secret_title")).Grow(1).MinWidth(0)
if iconAction(c, icons.Must("copy"), msg(c, "api_key_manager.copy")).Clicked() {
ui.Text(c, s.secret).Font(theme.MonoFont).Selectable().Label(core.Msg(c, "api_key_manager.secret_title")).Grow(1).MinWidth(0)
if core.IconAction(c, icons.Must("copy"), core.Msg(c, "api_key_manager.copy")).Clicked() {
c.WriteClipboard(s.secret)
c.Announce(msg(c, "api_key_manager.copied"))
c.Announce(core.Msg(c, "api_key_manager.copied"))
}
})
return
}
res := TextInput(c, &s.name, TextInputOptions{Label: msg(c, "api_key_manager.name"), Placeholder: msg(c, "api_key_manager.name"),
res := input.TextInput(c, &s.name, input.TextInputOptions{Label: core.Msg(c, "api_key_manager.name"), Placeholder: core.Msg(c, "api_key_manager.name"),
Disabled: s.pending, Error: s.err})
res.Input.AutoFocus()
if res.Submitted() && strings.TrimSpace(s.name) != "" && !s.pending {
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
+15 -12
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,7 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CoachmarkStep uses an anchor built in the current frame.
@@ -44,8 +47,8 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac
*open = false
return r
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
if b := cur.Anchor.Bounds(); b.W > 0 {
const pad = 4
ui.Overlay(c, func() {
@@ -54,26 +57,26 @@ func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) Coac
})
}
from := *step
r.Element = Popover(c, cur.Anchor, open, PopoverOptions{Title: cur.Title, Width: 300}, func() {
r.Element = overlay.Popover(c, cur.Anchor, open, overlay.PopoverOptions{Title: cur.Title, Width: 300}, func() {
ui.Column(c).Gap(d.Unit() * 3).Children(func() {
if cur.Body != "" {
ui.Text(c, cur.Body)
}
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "coachmark.count"), *step+1, len(steps))).TextColor(k.TextMuted).
FontSize(FontSize(c, theme.CaptionSize)).Grow(1)
if *step < len(steps)-1 && Button(c, msg(c, "coachmark.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
ui.Text(c, fmt.Sprintf(core.Msg(c, "coachmark.count"), *step+1, len(steps))).TextColor(k.TextMuted).
FontSize(core.FontSize(c, theme.CaptionSize)).Grow(1)
if *step < len(steps)-1 && input.Button(c, core.Msg(c, "coachmark.skip"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
*open, r.dismissed = false, true
}
if *step > 0 && Button(c, msg(c, "coachmark.back"), ButtonOptions{Variant: Secondary}).Clicked() {
if *step > 0 && input.Button(c, core.Msg(c, "coachmark.back"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
*step--
}
last := *step == len(steps)-1
next := msg(c, "coachmark.next")
next := core.Msg(c, "coachmark.next")
if last {
next = msg(c, "coachmark.done")
next = core.Msg(c, "coachmark.done")
}
if Button(c, next, ButtonOptions{}).AutoFocus().Clicked() {
if input.Button(c, next, input.ButtonOptions{}).AutoFocus().Clicked() {
if last {
*open, r.finished = false, true
} else {
@@ -1,16 +1,18 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func coachmarkView(c *ui.Context, open *bool, step *int, r *CoachmarkResult) {
var a, b *ui.Element
ui.Row(c).Gap(40).Padding(20).Children(func() {
a = Button(c, "Compose", ButtonOptions{})
b = Button(c, "Archive", ButtonOptions{Variant: Secondary})
a = input.Button(c, "Compose", input.ButtonOptions{})
b = input.Button(c, "Archive", input.ButtonOptions{Variant: input.Secondary})
})
*r = Coachmark(c, open, step, []CoachmarkStep{
{Anchor: a, Title: "Write here", Body: "Start a new letter."},
@@ -63,7 +65,7 @@ func TestCoachmarkSnapshot(t *testing.T) {
func TestCoachmarkDisabledAnchorCloses(t *testing.T) {
open, step, disabled := true, 0, false
tt := accountTester(600, 400, func(c *ui.Context) {
a := Button(c, "Compose", ButtonOptions{Disabled: disabled})
a := input.Button(c, "Compose", input.ButtonOptions{Disabled: disabled})
Coachmark(c, &open, &step, []CoachmarkStep{{Anchor: a, Title: "Write here"}})
})
disabled = true
+2
View File
@@ -0,0 +1,2 @@
// Package account holds MujicaUI settings and account management.
package account
+242
View File
@@ -0,0 +1,242 @@
package account_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/account"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func ExampleSettingsLayout() {
page := ""
sound := true
pages := []account.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.SettingsLayout(c, &page, pages, account.SettingsLayoutOptions{}, func(id string) {
account.SettingsSection(c, "Notifications", func() {
account.SettingsRow(c, "Sound", account.SettingsRowOptions{ID: "sound"}, func() *ui.Element {
return input.Switch(c, &sound, "", input.SwitchOptions{})
})
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSettingsSearch() {
query, page := "", "general"
entries := []account.SettingEntry{{ID: "sound", Page: "general", Label: "Sound", Keywords: []string{"audio"}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if e, ok := account.SettingsSearch(c, &query, entries, account.SettingsSearchOptions{}).Chosen(); ok {
page = e.Page
}
fmt.Println("page", page)
}
ui.Render(view, 400, 300, 1)
}
func ExampleThemeSelector() {
mode := core.System
view := func(c *ui.Context) {
core.Use(c, core.Settings{Mode: mode})
account.ThemeSelector(c, &mode, account.ThemeSelectorOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAccentColorPicker() {
accent := ui.Hex("#7D2034")
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.AccentColorPicker(c, &accent, account.AccentColorPickerOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleShortcutRecorder() {
palette := account.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.ShortcutRecorder(c, &palette, account.ShortcutRecorderOptions{
Label: "Command palette",
Taken: []account.ShortcutBinding{{Action: "Save", Shortcut: account.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleKeyboardShortcutsList() {
query := ""
groups := []account.ShortcutGroup{{Title: "General", Bindings: []account.ShortcutBinding{
{Action: "Command palette", Shortcut: account.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.KeyboardShortcutsList(c, &query, groups, account.KeyboardShortcutsListOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleLoginForm() {
var state account.LoginFormState
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.LoginForm(c, &state, account.LoginFormOptions{OnSubmit: func(account, password string, remember bool) {
// Submit credentials through the caller-provided auth service.
state.EndSubmit("")
}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOAuthButtons() {
providers := []account.OAuthProvider{{ID: "sso", Name: "Company SSO"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if id, ok := account.OAuthButtons(c, providers, account.OAuthButtonsOptions{}).Chosen(); ok {
fmt.Println("start sign-in with", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleTwoFactorInput() {
code := ""
resendAt := time.Now().Add(30 * time.Second)
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := account.TwoFactorInput(c, &code, account.TwoFactorInputOptions{ResendAt: resendAt})
if r.Completed() {
fmt.Println("verify the code")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleProfileEditor() {
saved := account.Profile{Name: "Sakiko", Bio: "Keyboard."}
profile := saved
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if account.ProfileEditor(c, &profile, account.ProfileEditorOptions{Saved: saved}).Save() {
saved = profile
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAccountSwitcher() {
current := "a"
accounts := []account.Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if account.AccountSwitcher(c, &current, accounts, account.AccountSwitcherOptions{}).Add() {
fmt.Println("sign in another account")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleWorkspaceSwitcher() {
current := "ave"
spaces := []account.Workspace{{ID: "ave", Name: "Ave Mujica"}, {ID: "mygo", Name: "MyGO!!!!!"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.WorkspaceSwitcher(c, &current, spaces, account.WorkspaceSwitcherOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleUserMenu() {
status := account.UserStatusOnline
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if account.UserMenu(c, &status, account.UserMenuOptions{Name: "Sakiko Togawa"}).SignOut() {
fmt.Println("sign out")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSessionList() {
sessions := []account.Session{{ID: "s1", Device: "MacBook Pro", Current: true}, {ID: "s2", Device: "iPhone", Location: "Osaka"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if id, ok := account.SessionList(c, sessions, account.SessionListOptions{}).Revoked(); ok {
fmt.Println("sign out", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleApiKeyManager() {
var state account.ApiKeyManagerState
keys := []account.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if name, ok := account.ApiKeyManager(c, &state, keys, account.ApiKeyManagerOptions{}).Create(); ok {
fmt.Println("create", name)
state.Reveal("secret from your key service")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleUsageQuota() {
items := []account.QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.UsageQuota(c, items, account.UsageQuotaOptions{ResetAt: time.Now().Add(72 * time.Hour)})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOnboardingWizard() {
step := 0
steps := []account.WizardStep{{Title: "Welcome"}, {Title: "Pick a theme"}, {Title: "Done"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if account.OnboardingWizard(c, &step, steps, account.OnboardingWizardOptions{}).Finished() {
fmt.Println("onboarded")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCoachmark() {
open, step := true, 0
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
compose := input.Button(c, "Compose", input.ButtonOptions{})
account.Coachmark(c, &open, &step, []account.CoachmarkStep{{Anchor: compose, Title: "Write here", Body: "Start a new letter."}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleWhatsNewDialog() {
open, hide := true, false
releases := []account.Release{{Version: "1.1", Changes: []account.ReleaseChange{{Kind: account.ChangeNew, Text: "500 components."}}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
account.WhatsNewDialog(c, &open, &hide, releases, account.WhatsNewDialogOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFeedbackWidget() {
var state account.FeedbackWidgetState
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if f, ok := account.FeedbackWidget(c, &state, account.FeedbackWidgetOptions{}).Submitted(); ok {
fmt.Println("send", f.Kind)
state.EndSubmit("")
}
}
ui.Render(view, 400, 300, 1)
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,6 +6,10 @@ import (
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
)
// FeedbackKind is what the feedback is about.
@@ -27,7 +31,7 @@ type Feedback struct {
// FeedbackWidgetState holds the draft and its form state.
type FeedbackWidgetState struct {
Draft Feedback
Form FormState
Form input.FormState
}
// EndSubmit clears successful drafts and preserves failed ones.
@@ -69,41 +73,41 @@ func FeedbackWidget(c *ui.Context, s *FeedbackWidgetState, opts FeedbackWidgetOp
if minLen == 0 {
minLen = 10
}
d := Density(c)
d := core.Density(c)
var r FeedbackWidgetResult
r.Element = ui.Column(c).Gap(d.Unit() * 4).Width(420).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
if disabled {
frozen := *s
frozen.Form.order = append([]string(nil), s.Form.order...)
frozen.Form.fields = make(map[string]*formFieldState, len(s.Form.fields))
for name, field := range s.Form.fields {
frozen.Form.Order = append([]string(nil), s.Form.Order...)
frozen.Form.Fields = make(map[string]*input.FormFieldState, len(s.Form.Fields))
for name, field := range s.Form.Fields {
copy := *field
frozen.Form.fields[name] = &copy
frozen.Form.Fields[name] = &copy
}
frozen.Form.requested, frozen.Form.awaiting = false, false
frozen.Form.Requested, frozen.Form.Awaiting = false, false
s = &frozen
}
locked := disabled || s.Form.Submitting()
r.Element.Children(func() {
kind := int(s.Draft.Kind)
var segs []Segment
var segs []navigation.Segment
for f := FeedbackBug; f <= FeedbackOther; f++ {
segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("feedback_widget.kind%d", f))})
segs = append(segs, navigation.Segment{Label: core.Msg(c, fmt.Sprintf("feedback_widget.kind%d", f))})
}
SegmentedControl(c, &kind, segs, SegmentedControlOptions{Equal: true, Label: msg(c, "feedback_widget.kind"), Disabled: locked})
navigation.SegmentedControl(c, &kind, segs, navigation.SegmentedControlOptions{Equal: true, Label: core.Msg(c, "feedback_widget.kind"), Disabled: locked})
if !locked {
s.Draft.Kind = FeedbackKind(kind)
}
res := Form(c, &s.Form, FormOptions{SubmitLabel: msg(c, "feedback_widget.send"), ResetLabel: msg(c, "feedback_widget.clear")}, func(f *FormScope) {
FormItem(c, f, "text", &s.Draft.Text, FormItemOptions[string]{Label: msg(c, "feedback_widget.message"), Required: true,
res := input.Form(c, &s.Form, input.FormOptions{SubmitLabel: core.Msg(c, "feedback_widget.send"), ResetLabel: core.Msg(c, "feedback_widget.clear")}, func(f *input.FormScope) {
input.FormItem(c, f, "text", &s.Draft.Text, input.FormItemOptions[string]{Label: core.Msg(c, "feedback_widget.message"), Required: true,
Validate: func(v string) string {
if n := len([]rune(strings.TrimSpace(v))); n < minLen {
return fmt.Sprintf(msg(c, "feedback_widget.short"), minLen)
return fmt.Sprintf(core.Msg(c, "feedback_widget.short"), minLen)
}
return ""
}}, func() *ui.Element {
return TextArea(c, &s.Draft.Text, TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input
return input.TextArea(c, &s.Draft.Text, input.TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input
})
if opts.Attachments != nil {
ui.Column(c).Gap(d.Unit()).Disabled(locked).Children(opts.Attachments)
@@ -1,9 +1,11 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestFeedbackWidgetValidatesSubmitsOnce(t *testing.T) {
@@ -63,7 +65,7 @@ func TestFeedbackWidgetSnapshot(t *testing.T) {
})
ui.Column(c).Padding(16).Children(func() {
FeedbackWidget(c, s, FeedbackWidgetOptions{Attachments: func() {
Button(c, "Attach screenshot", ButtonOptions{Variant: Secondary})
input.Button(c, "Attach screenshot", input.ButtonOptions{Variant: input.Secondary})
}})
})
})
@@ -74,7 +76,7 @@ func TestFeedbackWidgetPendingFreezesDraftAndAttachments(t *testing.T) {
attachments := 0
tt := accountTester(500, 500, func(c *ui.Context) {
FeedbackWidget(c, &s, FeedbackWidgetOptions{Attachments: func() {
if Button(c, "Attach", ButtonOptions{}).Clicked() {
if input.Button(c, "Attach", input.ButtonOptions{}).Clicked() {
attachments++
}
}})
@@ -115,7 +117,7 @@ func TestFeedbackWidgetDisabledKeepsFormState(t *testing.T) {
s := FeedbackWidgetState{Draft: Feedback{Text: "A long enough message."}}
s.Form.Submit()
accountTester(600, 500, func(c *ui.Context) { FeedbackWidget(c, &s, FeedbackWidgetOptions{Disabled: true}) })
if s.Form.Submitting() || !s.Form.requested || s.Form.fields != nil {
if s.Form.Submitting() || !s.Form.Requested || s.Form.Fields != nil {
t.Fatal("disabled frame edits form state")
}
}
+24
View File
@@ -0,0 +1,24 @@
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"reflect"
@@ -7,7 +7,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ShortcutGroup is a titled group of bindings in a KeyboardShortcutsList.
@@ -33,19 +36,19 @@ type shortcutsListState struct {
// KeyboardShortcutsList displays searchable, grouped shortcuts using visible rows.
func KeyboardShortcutsList(c *ui.Context, query *string, groups []ShortcutGroup, opts KeyboardShortcutsListOptions) *ui.Element {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
label := opts.Label
if label == "" {
label = msg(c, "keyboard_shortcuts_list.search")
label = core.Msg(c, "keyboard_shortcuts_list.search")
}
e := ui.Column(c).Gap(d.Unit() * 4).MinWidth(320)
st := ui.Local(e, "shortcuts", func() shortcutsListState { return shortcutsListState{} })
e.Children(func() {
SearchInput(c, query, SearchInputOptions{Placeholder: label, Label: label})
input.SearchInput(c, query, input.SearchInputOptions{Placeholder: label, Label: label})
shortcutsListPlan(st, groups, strings.ToLower(strings.TrimSpace(*query)))
if len(st.rows) == 0 {
EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: *query})
feedback.EmptyState(c, feedback.EmptyStateOptions{Kind: feedback.EmptyNoResults, Query: *query})
return
}
accountRows(c, len(st.rows), func(i int) any { return st.rows[i] }, func(i int) {
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
+15 -13
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"log/slog"
@@ -6,7 +6,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// LoginFormState holds the form; only OnSubmit sees the password.
@@ -42,42 +44,42 @@ func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) *ui.Elem
if opts.OnSubmit == nil {
panic("mujica: LoginForm needs OnSubmit")
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
accountLabel := opts.AccountLabel
if accountLabel == "" {
accountLabel = msg(c, "login_form.account")
accountLabel = core.Msg(c, "login_form.account")
}
e := ui.Column(c).Gap(d.Unit() * 4).Width(340).Disabled(opts.Disabled)
disabled := e.IsDisabled()
missing := func(v string) string {
if s.tried && strings.TrimSpace(v) == "" {
return msg(c, "form.required")
return core.Msg(c, "form.required")
}
return ""
}
submit := false
var acc, pw InputResult
var acc, pw input.InputResult
e.Children(func() {
FormField(c, accountLabel, FormFieldOptions{Required: true}, func() *ui.Element {
acc = TextInput(c, &s.Account, TextInputOptions{Disabled: disabled || s.submitting, Error: missing(s.Account)})
input.FormField(c, accountLabel, input.FormFieldOptions{Required: true}, func() *ui.Element {
acc = input.TextInput(c, &s.Account, input.TextInputOptions{Disabled: disabled || s.submitting, Error: missing(s.Account)})
return acc.Input
})
FormField(c, msg(c, "login_form.password"), FormFieldOptions{Required: true}, func() *ui.Element {
pw = PasswordInput(c, &s.password, PasswordInputOptions{Disabled: disabled || s.submitting, Error: missing(s.password)})
input.FormField(c, core.Msg(c, "login_form.password"), input.FormFieldOptions{Required: true}, func() *ui.Element {
pw = input.PasswordInput(c, &s.password, input.PasswordInputOptions{Disabled: disabled || s.submitting, Error: missing(s.password)})
return pw.Input
})
if acc.Changed() || pw.Changed() {
s.err = ""
}
Checkbox(c, &s.Remember, msg(c, "login_form.remember"), CheckboxOptions{Disabled: disabled || s.submitting})
input.Checkbox(c, &s.Remember, core.Msg(c, "login_form.remember"), input.CheckboxOptions{Disabled: disabled || s.submitting})
if s.err != "" {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16).Shrink(0)
ui.Text(c, s.err).Grow(1)
})
}
b := Button(c, msg(c, "login_form.submit"), ButtonOptions{Loading: s.submitting, Disabled: disabled})
b := input.Button(c, core.Msg(c, "login_form.submit"), input.ButtonOptions{Loading: s.submitting, Disabled: disabled})
b.FillWidth()
submit = b.Clicked() || acc.Submitted() || pw.Submitted()
})
@@ -1,4 +1,4 @@
package mujica
package account
import (
"bytes"
@@ -1,10 +1,13 @@
package mujica
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// OAuthProvider is a sign-in provider; icons are plain, not trademarks.
@@ -34,7 +37,7 @@ func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOpt
if len(providers) == 0 {
panic("mujica: OAuthButtons needs providers")
}
d := Density(c)
d := core.Density(c)
var r OAuthButtonsResult
r.Element = ui.Column(c).Gap(d.Unit() * 2).Width(340).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
@@ -43,9 +46,9 @@ func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOpt
if p.ID == "" || p.Name == "" {
panic("mujica: OAuthProvider needs an ID and a Name")
}
label := fmt.Sprintf(msg(c, "oauth_buttons.continue"), p.Name)
label := fmt.Sprintf(core.Msg(c, "oauth_buttons.continue"), p.Name)
ui.Column(c).Key(p.ID).AlignItems(ui.Stretch).Children(func() {
b := Button(c, label, ButtonOptions{Variant: Secondary, Icon: p.Icon, Loading: opts.Pending == p.ID,
b := input.Button(c, label, input.ButtonOptions{Variant: input.Secondary, Icon: p.Icon, Loading: opts.Pending == p.ID,
Disabled: opts.Pending != "" && opts.Pending != p.ID})
if b.Clicked() && !disabled && opts.Pending == "" {
r.chosen = p.ID
@@ -1,4 +1,4 @@
package mujica
package account
import (
"testing"
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,7 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// WizardStep is one page of an OnboardingWizard.
@@ -40,26 +43,26 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard
if len(steps) == 0 || *step < 0 || *step >= len(steps) {
panic(fmt.Sprintf("mujica: OnboardingWizard step %d outside %d steps", *step, len(steps)))
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
var r OnboardingWizardResult
from := *step
r.Element = ui.Column(c).Gap(d.Unit()*5).Width(520).Padding(d.Unit()*6).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border)
r.Element.Children(func() {
head := make([]Step, len(steps))
head := make([]navigation.Step, len(steps))
for i, s := range steps {
if s.Title == "" {
panic("mujica: WizardStep needs a Title")
}
head[i] = Step{Title: s.Title}
head[i] = navigation.Step{Title: s.Title}
}
at := *step
Steps(c, &at, head, StepsOptions{Label: msg(c, "onboarding_wizard.progress")})
navigation.Steps(c, &at, head, navigation.StepsOptions{Label: core.Msg(c, "onboarding_wizard.progress")})
cur := steps[*step]
ui.Column(c).Key(*step).Gap(d.Unit() * 3).Children(func() {
ui.Text(c, fmt.Sprintf(msg(c, "onboarding_wizard.count"), *step+1, len(steps))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, cur.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).FontWeight(600)
ui.Text(c, fmt.Sprintf(core.Msg(c, "onboarding_wizard.count"), *step+1, len(steps))).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
ui.Text(c, cur.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).FontWeight(600)
if cur.Description != "" {
ui.Text(c, cur.Description).TextColor(k.TextMuted)
}
@@ -70,18 +73,18 @@ func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts Onboard
last := *step == len(steps)-1
ready := cur.Ready == nil || cur.Ready()
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
if !opts.NoSkip && !last && Button(c, msg(c, "onboarding_wizard.skip"), ButtonOptions{Variant: Ghost}).Clicked() {
if !opts.NoSkip && !last && input.Button(c, core.Msg(c, "onboarding_wizard.skip"), input.ButtonOptions{Variant: input.Ghost}).Clicked() {
r.skipped = true
}
ui.Box(c).Grow(1)
if *step > 0 && Button(c, msg(c, "onboarding_wizard.back"), ButtonOptions{Variant: Secondary}).Clicked() {
if *step > 0 && input.Button(c, core.Msg(c, "onboarding_wizard.back"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
*step--
}
next := msg(c, "onboarding_wizard.next")
next := core.Msg(c, "onboarding_wizard.next")
if last {
next = msg(c, "onboarding_wizard.finish")
next = core.Msg(c, "onboarding_wizard.finish")
}
if Button(c, next, ButtonOptions{Disabled: !ready}).AutoFocus().Clicked() && ready {
if input.Button(c, next, input.ButtonOptions{Disabled: !ready}).AutoFocus().Clicked() && ready {
if last {
r.finished = true
} else {
@@ -1,9 +1,11 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func TestOnboardingWizardStepsSkipFinish(t *testing.T) {
@@ -15,7 +17,7 @@ func TestOnboardingWizardStepsSkipFinish(t *testing.T) {
{Title: "Done"},
}
tt := accountTester(600, 500, func(c *ui.Context) {
steps[1].Content = func() { TextInput(c, &name, TextInputOptions{Label: "Name"}) }
steps[1].Content = func() { input.TextInput(c, &name, input.TextInputOptions{Label: "Name"}) }
r := OnboardingWizard(c, &step, steps, OnboardingWizardOptions{})
if r.Finished() {
finished++
+18 -15
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"log/slog"
@@ -7,8 +7,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Profile is what a ProfileEditor edits.
@@ -53,28 +56,28 @@ func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) Profile
if limit == 0 {
limit = 160
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
var r ProfileEditorResult
r.Element = ui.Column(c).Gap(d.Unit() * 4).Width(420).Disabled(opts.Disabled)
locked := r.Element.IsDisabled() || opts.Saving
dirty := *p != opts.Saved
nameErr := ""
if strings.TrimSpace(p.Name) == "" {
nameErr = msg(c, "form.required")
nameErr = core.Msg(c, "form.required")
}
r.Element.Children(func() {
ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Center).Children(func() {
Avatar(c, p.Name, AvatarOptions{Image: opts.Avatar, Size: 64})
if Button(c, msg(c, "profile_editor.avatar"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
display.Avatar(c, p.Name, display.AvatarOptions{Image: opts.Avatar, Size: 64})
if input.Button(c, core.Msg(c, "profile_editor.avatar"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
r.avatar = true
}
})
FormField(c, msg(c, "profile_editor.name"), FormFieldOptions{Required: true}, func() *ui.Element {
return TextInput(c, &p.Name, TextInputOptions{Disabled: locked, Error: nameErr}).Input
input.FormField(c, core.Msg(c, "profile_editor.name"), input.FormFieldOptions{Required: true}, func() *ui.Element {
return input.TextInput(c, &p.Name, input.TextInputOptions{Disabled: locked, Error: nameErr}).Input
})
FormField(c, msg(c, "profile_editor.bio"), FormFieldOptions{}, func() *ui.Element {
return TextArea(c, &p.Bio, TextAreaOptions{MaxLength: limit, Disabled: locked}).Input
input.FormField(c, core.Msg(c, "profile_editor.bio"), input.FormFieldOptions{}, func() *ui.Element {
return input.TextArea(c, &p.Bio, input.TextAreaOptions{MaxLength: limit, Disabled: locked}).Input
})
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
if dirty {
@@ -82,16 +85,16 @@ func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) Profile
ui.Box(c).Size(10, 10).Draw(func(pt *ui.Painter, rect ui.Rect) {
pt.FillPath(internal.Diamond(internal.Snap(pt, rect.X+5), internal.Snap(pt, rect.Y+5), 10), k.Warning)
})
ui.Text(c, msg(c, "profile_editor.unsaved")).TextColor(k.Warning).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600)
ui.Text(c, core.Msg(c, "profile_editor.unsaved")).TextColor(k.Warning).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600)
})
} else {
ui.Box(c).Grow(1)
}
if Button(c, msg(c, "profile_editor.discard"), ButtonOptions{Variant: Ghost, Disabled: locked || !dirty}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "profile_editor.discard"), input.ButtonOptions{Variant: input.Ghost, Disabled: locked || !dirty}).Clicked() && !locked {
*p, r.reset = opts.Saved, true
}
valid := strings.TrimSpace(p.Name) != "" && utf8.RuneCountInString(p.Bio) <= limit
if Button(c, msg(c, "profile_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: r.Element.IsDisabled() || !dirty || !valid}).Clicked() && !locked && valid {
if input.Button(c, core.Msg(c, "profile_editor.save"), input.ButtonOptions{Loading: opts.Saving, Disabled: r.Element.IsDisabled() || !dirty || !valid}).Clicked() && !locked && valid {
r.save = true
}
})
@@ -1,4 +1,4 @@
package mujica
package account
import (
"testing"
+18 -14
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -8,7 +8,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Session describes a device without carrying its authentication token.
@@ -46,8 +50,8 @@ type sessionListState struct {
// SessionList displays devices and requests sign-out through events.
func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) SessionListResult {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
var r SessionListResult
r.Element = ui.Column(c).Gap(d.Unit() * 3).MinWidth(420).Disabled(opts.Disabled)
st := ui.Local(r.Element, "sessions", func() sessionListState { return sessionListState{} })
@@ -67,7 +71,7 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
}
others := st.others
r.Element.Children(func() {
ui.Column(c).Role(ui.RoleList).Label(msg(c, "session_list.label")).Children(func() {
ui.Column(c).Role(ui.RoleList).Label(core.Msg(c, "session_list.label")).Children(func() {
accountRows(c, len(sessions), func(i int) any { return sessions[i].ID }, func(i int) {
s := sessions[i]
ui.Row(c).Key(s.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
@@ -76,14 +80,14 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, s.Device).FontWeight(600).SingleLine()
if s.Current {
Badge(c, BadgeOptions{Text: msg(c, "session_list.current")}, nil)
display.Badge(c, display.BadgeOptions{Text: core.Msg(c, "session_list.current")}, nil)
}
})
ui.Text(c, sessionListDetail(c, s)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
ui.Text(c, sessionListDetail(c, s)).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
})
if !s.Current {
b := Button(c, msg(c, "session_list.sign_out"), ButtonOptions{Variant: Secondary, Disabled: disabled}).
Label(fmt.Sprintf(msg(c, "session_list.sign_out_of"), s.Device))
b := input.Button(c, core.Msg(c, "session_list.sign_out"), input.ButtonOptions{Variant: input.Secondary, Disabled: disabled}).
Label(fmt.Sprintf(core.Msg(c, "session_list.sign_out_of"), s.Device))
if b.Clicked() && !disabled {
r.revoke = s.ID
}
@@ -92,7 +96,7 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
})
})
ui.Row(c).Children(func() {
if Button(c, msg(c, "session_list.sign_out_others"), ButtonOptions{Variant: Danger, Disabled: disabled || others == 0}).Clicked() && !disabled {
if input.Button(c, core.Msg(c, "session_list.sign_out_others"), input.ButtonOptions{Variant: input.Danger, Disabled: disabled || others == 0}).Clicked() && !disabled {
st.confirm = true
}
})
@@ -100,8 +104,8 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
if disabled || others == 0 {
st.confirm = false
}
res := AlertDialog(c, &st.confirm, AlertDialogOptions{Title: msg(c, "session_list.confirm_title"),
Description: fmt.Sprintf(msg(c, "session_list.confirm_text"), others), ConfirmLabel: msg(c, "session_list.sign_out_others")})
res := overlay.AlertDialog(c, &st.confirm, overlay.AlertDialogOptions{Title: core.Msg(c, "session_list.confirm_title"),
Description: fmt.Sprintf(core.Msg(c, "session_list.confirm_text"), others), ConfirmLabel: core.Msg(c, "session_list.sign_out_others")})
r.others = res.Confirmed && !disabled
if r.revoke != "" || r.others {
slog.Debug("mujica: session_list sign out", "others", r.others)
@@ -111,9 +115,9 @@ func SessionList(c *ui.Context, sessions []Session, opts SessionListOptions) Ses
// sessionListDetail formats the location and last active time.
func sessionListDetail(c *ui.Context, s Session) string {
when := msg(c, "session_list.now")
when := core.Msg(c, "session_list.now")
if ago := c.Now().Sub(s.LastActive); s.LastActive.IsZero() {
when = msg(c, "session_list.unknown")
when = core.Msg(c, "session_list.unknown")
} else if ago >= time.Minute {
when = s.LastActive.Format("2006-01-02 15:04")
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -1,4 +1,4 @@
package mujica
package account
import (
"log/slog"
@@ -6,7 +6,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SettingsPage is one page of a SettingsLayout's navigation.
@@ -22,18 +24,18 @@ type SettingsLayoutOptions struct {
}
// SettingsLayout builds section navigation and independently scrolling content.
func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts SettingsLayoutOptions, content func(id string)) ChoiceResult {
func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts SettingsLayoutOptions, content func(id string)) core.ChoiceResult {
if len(pages) == 0 || content == nil {
panic("mujica: SettingsLayout needs pages and content")
}
found := false
items := make([]SidebarItem, len(pages))
items := make([]navigation.SidebarItem, len(pages))
for i, p := range pages {
if p.ID == "" || p.Label == "" {
panic("mujica: SettingsLayout page needs an ID and a Label")
}
found = found || p.ID == *page
items[i] = SidebarItem{ID: p.ID, Label: p.Label, Icon: p.Icon}
items[i] = navigation.SidebarItem{ID: p.ID, Label: p.Label, Icon: p.Icon}
}
if *page == "" {
*page, found = pages[0].ID, true
@@ -43,16 +45,16 @@ func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts Sett
}
title := opts.Title
if title == "" {
title = msg(c, "settings_layout.title")
title = core.Msg(c, "settings_layout.title")
}
k := Tokens(c)
d := Density(c)
var r ChoiceResult
k := core.Tokens(c)
d := core.Density(c)
var r core.ChoiceResult
r.Element = ui.Row(c).Fill().AlignItems(ui.Stretch).Background(k.Background)
r.Element.Children(func() {
nav := Sidebar(c, page, []SidebarSection{{Title: title, Items: items}}, SidebarOptions{Width: 220})
nav := navigation.Sidebar(c, page, []navigation.SidebarSection{{Title: title, Items: items}}, navigation.SidebarOptions{Width: 220})
if nav.Changed() {
r.changed = true
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: settings_layout page", "page", *page)
}
ui.Scroll(c).Grow(1).MinWidth(0).Padding(d.Unit()*6, d.Unit()*8).Children(func() {
@@ -64,11 +66,11 @@ func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts Sett
// SettingsSection groups rows under a serif heading.
func SettingsSection(c *ui.Context, title string, rows func()) *ui.Element {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
e := ui.Column(c).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(title)
e.Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Role(ui.RoleNone)
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Role(ui.RoleNone)
ui.Column(c).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Padding(0, d.Unit()*4).
Dividers(theme.BorderWidth, k.Border).Children(rows)
})
@@ -96,8 +98,8 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f
if label == "" || control == nil {
panic("mujica: SettingsRow needs a label and a control")
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
row := ui.Row(c).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*4).AlignItems(ui.Center).Padding(d.Unit()*3, 0)
if opts.ID != "" {
row.Key(opts.ID)
@@ -110,7 +112,7 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f
text.Children(func() {
ui.Text(c, label).FontWeight(500).Role(ui.RoleNone)
if opts.Description != "" {
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
}
})
ui.Row(c).Shrink(0).Children(func() { ctrl = control() })
@@ -129,7 +131,7 @@ func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control f
slog.Debug("mujica: settings_layout reveal", "row", opts.ID)
}
// A revealed row's face fades from the selection color.
if dur := Motion(c, theme.StateDuration); !st.at.IsZero() && dur > 0 {
if dur := core.Motion(c, theme.StateDuration); !st.at.IsZero() && dur > 0 {
if t := c.Now().Sub(st.at); t < dur {
row.Background(k.Selection.Alpha(1 - float32(t)/float32(dur)))
c.AnimationFrame()
@@ -1,15 +1,18 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
// accountTester runs a themed view inside a padded column.
func accountTester(w, h int, view func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
}, w, h)
}
@@ -22,13 +25,13 @@ func settingsView(c *ui.Context, page *string, sound, track *bool, changes *int)
case "general":
SettingsSection(c, "Notifications", func() {
SettingsRow(c, "Sound", SettingsRowOptions{ID: "sound", Description: "Chime on new messages."}, func() *ui.Element {
return Switch(c, sound, "", SwitchOptions{})
return input.Switch(c, sound, "", input.SwitchOptions{})
})
})
case "privacy":
SettingsSection(c, "Data", func() {
SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() *ui.Element {
return Switch(c, track, "", SwitchOptions{})
return input.Switch(c, track, "", input.SwitchOptions{})
})
})
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"log/slog"
@@ -9,8 +9,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SettingEntry identifies a searchable setting and its destination.
@@ -60,12 +62,12 @@ func SettingsSearch(c *ui.Context, query *string, entries []SettingEntry, opts S
if limit == 0 {
limit = 8
}
d := Density(c)
d := core.Density(c)
var r SettingsSearchResult
r.Element = ui.Column(c).Gap(d.Unit() * 2).MinWidth(280)
st := ui.Local(r.Element, "search", func() settingsSearchState { return settingsSearchState{} })
r.Element.Children(func() {
res := SearchInput(c, query, SearchInputOptions{Placeholder: msg(c, "settings_search.placeholder"), Label: msg(c, "settings_search.placeholder")})
res := input.SearchInput(c, query, input.SearchInputOptions{Placeholder: core.Msg(c, "settings_search.placeholder"), Label: core.Msg(c, "settings_search.placeholder")})
in := res.Input
q := strings.ToLower(strings.TrimSpace(*query))
settingsSearchPlan(st, entries, q, limit)
@@ -87,7 +89,7 @@ func SettingsSearch(c *ui.Context, query *string, entries []SettingEntry, opts S
r.chosen = &entries[hits[st.cursor]]
}
if q != "" && len(hits) == 0 {
ui.Text(c, msg(c, "settings_search.none")).TextColor(Tokens(c).TextMuted).Role(ui.RoleStatus)
ui.Text(c, core.Msg(c, "settings_search.none")).TextColor(core.Tokens(c).TextMuted).Role(ui.RoleStatus)
}
list := accountRows(c, len(hits), func(n int) any { return entries[hits[n]].ID }, func(n int) {
i := hits[n]
@@ -131,8 +133,8 @@ func settingsSearchPlan(st *settingsSearchState, entries []SettingEntry, q strin
// settingsSearchHit builds a marked, clickable search result.
func settingsSearchHit(c *ui.Context, e SettingEntry, q string, current bool) bool {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
b := ui.ButtonBase(c).Key(e.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(e.Label).Description(e.Description).Selected(current).Gap(d.Unit() * 2)
internal.Ring(b, k.Focus, theme.ControlRadius)
@@ -160,8 +162,8 @@ func settingsSearchHit(c *ui.Context, e SettingEntry, q string, current bool) bo
// settingsSearchSpans highlights matches while preserving original Unicode text.
func settingsSearchSpans(c *ui.Context, s, q string, size float32) []ui.Span {
k := Tokens(c)
size = FontSize(c, size)
k := core.Tokens(c)
size = core.FontSize(c, size)
plain := func(t string) ui.Span { return ui.Span{Text: t, Size: size, Color: k.Text} }
if q == "" {
return []ui.Span{plain(s)}
@@ -1,9 +1,11 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var settingEntries = []SettingEntry{
@@ -74,7 +76,7 @@ func TestSettingsSearchHighlightsUnicodeCaseMapping(t *testing.T) {
func TestSettingsSearchPlainSpansHaveReadableColor(t *testing.T) {
accountTester(400, 220, func(c *ui.Context) {
for _, span := range settingsSearchSpans(c, "Usage reports", "us", 14) {
if span.Color != Tokens(c).Text {
if span.Color != core.Tokens(c).Text {
t.Error("search text does not use the readable Text token")
}
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -8,9 +8,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Shortcut combines modifiers and a key; zero means unassigned.
@@ -81,10 +83,10 @@ var shortcutNames = map[ui.Key]string{
// shortcutKbd displays keycaps or a muted unassigned mark.
func shortcutKbd(c *ui.Context, s Shortcut) {
if s.IsZero() {
ui.Text(c, "—").TextColor(Tokens(c).TextMuted)
ui.Text(c, "—").TextColor(core.Tokens(c).TextMuted)
return
}
Kbd(c, s.keys()...)
display.Kbd(c, s.keys()...)
}
// ShortcutBinding names an action that already owns a shortcut.
@@ -109,12 +111,12 @@ type shortcutRecorderState struct {
}
// ShortcutRecorder records key combinations; Escape cancels.
func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptions) ChoiceResult {
func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptions) core.ChoiceResult {
if opts.Label == "" {
panic("mujica: ShortcutRecorder needs a Label")
}
d := Density(c)
var r ChoiceResult
d := core.Density(c)
var r core.ChoiceResult
r.Element = ui.Column(c).Gap(d.Unit()).AlignItems(ui.Start).Disabled(opts.Disabled)
r.Element.Children(func() {
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(160).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).
@@ -129,11 +131,11 @@ func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptio
st.caught, st.recording, st.conflict = nil, false, ""
for _, t := range opts.Taken {
if t.Shortcut == s && s != *value {
st.conflict = fmt.Sprintf(msg(c, "shortcut_recorder.conflict"), s.String(), t.Action)
st.conflict = fmt.Sprintf(core.Msg(c, "shortcut_recorder.conflict"), s.String(), t.Action)
}
}
if st.conflict == "" && s != *value {
*value, r.changed = s, true
*value, r = s, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: shortcut_recorder set", "shortcut", s.String())
}
}
@@ -177,11 +179,11 @@ func shortcutRecorderKey(st *shortcutRecorderState, ev ui.InputEvent) bool {
// shortcutRecorderLook displays the shortcut or recording prompt.
func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderState, value Shortcut, name string) {
k := Tokens(c)
k := core.Tokens(c)
internal.Ring(b, k.Focus, theme.ControlRadius)
label := name + ": " + value.String()
if value.IsZero() {
label = name + ": " + msg(c, "shortcut_recorder.none")
label = name + ": " + core.Msg(c, "shortcut_recorder.none")
}
b.Label(label)
bg, border := k.Surface, k.ControlBorder
@@ -193,7 +195,7 @@ func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderStat
b.Background(bg).Border(theme.BorderWidth, border)
b.Children(func() {
if st.recording {
ui.Text(c, msg(c, "shortcut_recorder.press")).TextColor(k.AccentText).SingleLine()
ui.Text(c, core.Msg(c, "shortcut_recorder.press")).TextColor(k.AccentText).SingleLine()
} else {
shortcutKbd(c, value)
}
@@ -205,13 +207,13 @@ func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderStat
func shortcutRecorderNote(c *ui.Context, st *shortcutRecorderState) {
text := st.conflict
if st.needMod && st.recording {
text = msg(c, "shortcut_recorder.needs_modifier")
text = core.Msg(c, "shortcut_recorder.needs_modifier")
}
if text == "" {
return
}
ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(Tokens(c).Warning).Role(ui.RoleStatus).Children(func() {
ui.Row(c).Gap(4).AlignItems(ui.Center).TextColor(core.Tokens(c).Warning).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize))
})
}
@@ -1,10 +1,12 @@
package mujica
package account
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func shortcutRecorderTester(v *Shortcut, opts *ShortcutRecorderOptions, n *int) *ui.Tester {
@@ -81,7 +83,7 @@ func TestShortcutRecorderFunctionKeyAndTabCancellation(t *testing.T) {
if ShortcutRecorder(c, &v, opts).Changed() {
n++
}
Button(c, "Next control", ButtonOptions{})
input.Button(c, "Next control", input.ButtonOptions{})
})
if err := tt.Click("Refresh: none"); err != nil {
t.Fatal(err)
+17 -16
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,9 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ThemeSelectorOptions configure a ThemeSelector.
@@ -17,14 +18,14 @@ type ThemeSelectorOptions struct {
}
// ThemeSelector selects an appearance using preview cards.
func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceResult {
if *mode != System && *mode != Light && *mode != Dark {
func ThemeSelector(c *ui.Context, mode *core.Mode, opts ThemeSelectorOptions) core.ChoiceResult {
if *mode != core.System && *mode != core.Light && *mode != core.Dark {
panic(fmt.Sprintf("mujica: ThemeSelector mode %d is not System, Light or Dark", *mode))
}
k := Tokens(c)
d := Density(c)
var r ChoiceResult
r.Element = ui.Row(c).Gap(d.Unit() * 3).Wrap().Role(ui.RoleRadioGroup).Label(msg(c, "theme_selector.label")).
k := core.Tokens(c)
d := core.Density(c)
var r core.ChoiceResult
r.Element = ui.Row(c).Gap(d.Unit() * 3).Wrap().Role(ui.RoleRadioGroup).Label(core.Msg(c, "theme_selector.label")).
FocusGroup(ui.Horizontal).Disabled(opts.Disabled)
selected := mode
if r.Element.IsDisabled() {
@@ -32,10 +33,10 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
selected = &copy
}
r.Element.Children(func() {
for _, m := range []Mode{System, Light, Dark} {
card := ui.RadioBase(c, selected, m).Column().Gap(d.Unit() * 2).Padding(d.Unit() * 2).Radius(theme.CardRadius).AlignItems(ui.Stretch).Transition(colorFade)
for _, m := range []core.Mode{core.System, core.Light, core.Dark} {
card := ui.RadioBase(c, selected, m).Column().Gap(d.Unit() * 2).Padding(d.Unit() * 2).Radius(theme.CardRadius).AlignItems(ui.Stretch).Transition(core.ColorFade)
if card.Changed() && !card.IsDisabled() {
r.changed = true
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: theme_selector chose", "mode", m.String())
}
on := *mode == m
@@ -52,9 +53,9 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
card.Children(func() {
ui.Box(c).Size(132, 84).Role(ui.RoleNone).Draw(func(p *ui.Painter, rect ui.Rect) {
switch m {
case Light:
case core.Light:
themePreview(p, rect, theme.Light())
case Dark:
case core.Dark:
themePreview(p, rect, theme.Dark())
default:
half := ui.Rect{X: rect.X, Y: rect.Y, W: rect.W / 2, H: rect.H}
@@ -66,7 +67,7 @@ func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceR
if on {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
ui.Text(c, msg(c, "theme_selector."+m.String())).SingleLine()
ui.Text(c, core.Msg(c, "theme_selector."+m.String())).SingleLine()
})
})
}
@@ -1,13 +1,15 @@
package mujica
package account
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
mode, n := System, 0
mode, n := core.System, 0
disabled := false
tt := accountTester(600, 220, func(c *ui.Context) {
if ThemeSelector(c, &mode, ThemeSelectorOptions{Disabled: disabled}).Changed() {
@@ -17,18 +19,18 @@ func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
if err := tt.Click("Dark"); err != nil {
t.Fatal(err)
}
if mode != Dark || n != 1 {
if mode != core.Dark || n != 1 {
t.Fatalf("click: %v %d", mode, n)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeySpace)
if mode != Light || n != 2 {
if mode != core.Light || n != 2 {
t.Fatalf("keys: %v %d", mode, n)
}
disabled = true
tt.Frame()
_ = tt.Click("System")
if mode != Light {
if mode != core.Light {
t.Fatal("disabled selector changed")
}
defer func() {
@@ -36,13 +38,13 @@ func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
t.Fatal("unknown mode accepted")
}
}()
bad := Mode(9)
bad := core.Mode(9)
accountTester(100, 100, func(c *ui.Context) { ThemeSelector(c, &bad, ThemeSelectorOptions{}) })
}
func TestThemeSelectorSnapshot(t *testing.T) {
snapshot(t, "theme_selector", 520, 170, func(c *ui.Context) {
m := ui.Local(c.Root(), "m", func() Mode { return Dark })
m := ui.Local(c.Root(), "m", func() core.Mode { return core.Dark })
ui.Column(c).Padding(16).Children(func() { ThemeSelector(c, m, ThemeSelectorOptions{}) })
})
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -8,8 +8,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// TwoFactorInputOptions configure a TwoFactorInput.
@@ -38,30 +40,30 @@ func TwoFactorInput(c *ui.Context, code *string, opts TwoFactorInputOptions) Two
if code == nil || len(*code) > 6 || strings.Trim(*code, "0123456789") != "" {
panic("mujica: TwoFactorInput needs up to six digits")
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
var r TwoFactorResult
r.Element = ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
pin := PinInput(c, code, PinInputOptions{Length: 6, Label: msg(c, "two_factor_input.label"), Disabled: disabled, Invalid: opts.Error != ""})
pin := input.PinInput(c, code, input.PinInputOptions{Length: 6, Label: core.Msg(c, "two_factor_input.label"), Disabled: disabled, Invalid: opts.Error != ""})
r.completed = pin.Completed() && !disabled
if opts.Error != "" {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(14, 14)
ui.Text(c, opts.Error).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, opts.Error).FontSize(core.FontSize(c, theme.CaptionSize))
})
}
wait := opts.ResendAt.Sub(c.Now())
label := msg(c, "two_factor_input.resend")
label := core.Msg(c, "two_factor_input.resend")
if wait > 0 {
secs := int((wait + time.Second - 1) / time.Second)
label = fmt.Sprintf(msg(c, "two_factor_input.resend_in"), secs/60, secs%60)
label = fmt.Sprintf(core.Msg(c, "two_factor_input.resend_in"), secs/60, secs%60)
// Wake when the shown second changes.
c.After(wait - time.Duration(secs-1)*time.Second)
}
ui.Row(c).Children(func() {
b := Button(c, label, ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled || wait > 0})
b := input.Button(c, label, input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("refresh-cw"), Disabled: disabled || wait > 0})
if b.Clicked() && !disabled && wait <= 0 {
r.resend = true
slog.Debug("mujica: two_factor_input resend")
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
+17 -15
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -7,8 +7,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// QuotaItem is one metered resource: Used of Limit, in Unit.
@@ -35,8 +37,8 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
if warn < 0 || warn > 1 || math.IsNaN(warn) {
panic("mujica: UsageQuota Warn must be between 0 and 1")
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
e := ui.Column(c).Gap(d.Unit() * 4).MinWidth(320)
e.Children(func() {
for i, it := range items {
@@ -47,22 +49,22 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
col, icon, note := k.AccentText, "", ""
switch {
case share >= 1:
col, icon, note = k.Danger, "circle-x", msg(c, "usage_quota.over")
col, icon, note = k.Danger, "circle-x", core.Msg(c, "usage_quota.over")
case share >= warn:
col, icon, note = k.Warning, "triangle-alert", msg(c, "usage_quota.near")
col, icon, note = k.Warning, "triangle-alert", core.Msg(c, "usage_quota.near")
}
amount := fmt.Sprintf(msg(c, "usage_quota.amount"), statisticFormat(it.Used, 0), statisticFormat(it.Limit, 0), it.Unit)
amount := fmt.Sprintf(core.Msg(c, "usage_quota.amount"), data.StatisticFormat(it.Used, 0), data.StatisticFormat(it.Limit, 0), it.Unit)
ui.Column(c).Key(i).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label).
Description(fmt.Sprintf("%s, %s%% %s", amount, statisticFormat(share*100, 0), note)).Children(func() {
Description(fmt.Sprintf("%s, %s%% %s", amount, data.StatisticFormat(share*100, 0), note)).Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, it.Label).FontWeight(500).Grow(1)
if icon != "" {
ui.Row(c).Gap(d.Unit() / 2).AlignItems(ui.Center).TextColor(col).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(12, 12)
ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600)
ui.Text(c, note).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600)
})
}
ui.Text(c, amount).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
ui.Text(c, amount).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
})
ui.Box(c).Height(6).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.SurfaceHover, theme.ControlRadius)
@@ -73,7 +75,7 @@ func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.El
if !opts.ResetAt.IsZero() {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
ui.Icon(c, icons.Must("clock")).Size(12, 12)
ui.Text(c, usageQuotaReset(c, opts.ResetAt)).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, usageQuotaReset(c, opts.ResetAt)).FontSize(core.FontSize(c, theme.CaptionSize))
})
}
})
@@ -85,9 +87,9 @@ func usageQuotaReset(c *ui.Context, at time.Time) string {
left := at.Sub(c.Now())
switch {
case left <= 0:
return msg(c, "usage_quota.reset_due")
return core.Msg(c, "usage_quota.reset_due")
case left < 24*time.Hour:
return fmt.Sprintf(msg(c, "usage_quota.reset_hours"), int(math.Ceil(left.Hours())))
return fmt.Sprintf(core.Msg(c, "usage_quota.reset_hours"), int(math.Ceil(left.Hours())))
}
return fmt.Sprintf(msg(c, "usage_quota.reset_on"), at.Format("2006-01-02"))
return fmt.Sprintf(core.Msg(c, "usage_quota.reset_on"), at.Format("2006-01-02"))
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
+14 -11
View File
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,8 +6,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// UserStatus is the presence a user shows to others.
@@ -50,11 +53,11 @@ func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuR
if *status < UserStatusOnline || *status > UserStatusInvisible {
panic(fmt.Sprintf("mujica: UserMenu status %d is unknown", *status))
}
k := Tokens(c)
k := core.Tokens(c)
var r UserMenuResult
word := msg(c, fmt.Sprintf("user_menu.status%d", *status))
word := core.Msg(c, fmt.Sprintf("user_menu.status%d", *status))
b := ui.ButtonBase(c).Role(ui.RoleMenuButton).Size(36, 36).Radius(18).Disabled(opts.Disabled).
Label(fmt.Sprintf(msg(c, "user_menu.label"), opts.Name, word))
Label(fmt.Sprintf(core.Msg(c, "user_menu.label"), opts.Name, word))
r.Element = b
internal.Ring(b, k.Focus, 18)
open := ui.Local(b, "open", func() bool { return false })
@@ -65,9 +68,9 @@ func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuR
*open = true
}
st := *status
b.Children(func() { Avatar(c, opts.Name, AvatarOptions{Image: opts.Avatar, Size: 32}) })
b.Children(func() { display.Avatar(c, opts.Name, display.AvatarOptions{Image: opts.Avatar, Size: 32}) })
b.DrawOver(func(p *ui.Painter, rect ui.Rect) { userMenuDot(p, k, rect.X+rect.W-6, rect.Y+rect.H-6, st) })
popupMenuOpen(c, b, open, func(m *PopupMenu) {
overlay.PopupMenuOpen(c, b, open, func(m *overlay.PopupMenu) {
head := opts.Name
if opts.Email != "" {
head += " · " + opts.Email
@@ -75,15 +78,15 @@ func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuR
m.Group(head, func() {
for s := UserStatusOnline; s <= UserStatusInvisible; s++ {
on := s == *status
if m.Check(msg(c, fmt.Sprintf("user_menu.status%d", s)), &on, PopupMenuItemOptions{}) && s != *status && !disabled {
if m.Check(core.Msg(c, fmt.Sprintf("user_menu.status%d", s)), &on, overlay.PopupMenuItemOptions{}) && s != *status && !disabled {
*status, r.changed = s, true
slog.Debug("mujica: user_menu status", "status", s)
}
}
})
m.Separator()
r.settings = m.Item(msg(c, "user_menu.settings"), PopupMenuItemOptions{}) && !disabled
r.leave = m.Item(msg(c, "user_menu.sign_out"), PopupMenuItemOptions{}) && !disabled
r.settings = m.Item(core.Msg(c, "user_menu.settings"), overlay.PopupMenuItemOptions{}) && !disabled
r.leave = m.Item(core.Msg(c, "user_menu.sign_out"), overlay.PopupMenuItemOptions{}) && !disabled
})
if r.settings || r.leave {
slog.Debug("mujica: user_menu chose", "settings", r.settings, "sign_out", r.leave)
@@ -1,4 +1,4 @@
package mujica
package account
import (
"testing"
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -6,7 +6,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/display"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ChangeKind tags a release note.
@@ -37,7 +41,7 @@ type WhatsNewDialogOptions struct {
}
// WhatsNewDialog displays release notes and a persistent hide choice.
func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, opts WhatsNewDialogOptions) ChoiceResult {
func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, opts WhatsNewDialogOptions) core.ChoiceResult {
if len(releases) == 0 {
panic("mujica: WhatsNewDialog needs releases")
}
@@ -54,19 +58,19 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o
if ui.Box(c).IsDisabled() {
*open = false
}
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
title := opts.Title
if title == "" {
title = msg(c, "whats_new_dialog.title")
title = core.Msg(c, "whats_new_dialog.title")
}
var r ChoiceResult
var r core.ChoiceResult
was := *open
before := *hide
r.Element = Dialog(c, open, DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() {
Checkbox(c, hide, msg(c, "whats_new_dialog.hide"), CheckboxOptions{})
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() {
input.Checkbox(c, hide, core.Msg(c, "whats_new_dialog.hide"), input.CheckboxOptions{})
ui.Box(c).Grow(1)
if Button(c, msg(c, "whats_new_dialog.close"), ButtonOptions{}).AutoFocus().Clicked() {
if input.Button(c, core.Msg(c, "whats_new_dialog.close"), input.ButtonOptions{}).AutoFocus().Clicked() {
*open = false
}
}}, func() {
@@ -76,22 +80,22 @@ func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, o
}
ui.Column(c).Key(i).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.End).Children(func() {
ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
if rel.Date != "" {
ui.Text(c, rel.Date).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
ui.Text(c, rel.Date).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize))
}
})
for _, ch := range rel.Changes {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() {
tone := map[ChangeKind]BadgeTone{ChangeNew: BadgeAccent, ChangeImproved: BadgeInfo, ChangeFixed: BadgeSuccess}[ch.Kind]
Badge(c, BadgeOptions{Text: msg(c, fmt.Sprintf("whats_new_dialog.kind%d", ch.Kind)), Tone: tone}, nil).Shrink(0)
tone := map[ChangeKind]display.BadgeTone{ChangeNew: display.BadgeAccent, ChangeImproved: display.BadgeInfo, ChangeFixed: display.BadgeSuccess}[ch.Kind]
display.Badge(c, display.BadgeOptions{Text: core.Msg(c, fmt.Sprintf("whats_new_dialog.kind%d", ch.Kind)), Tone: tone}, nil).Shrink(0)
ui.Text(c, ch.Text).Grow(1).MinWidth(0)
})
}
})
}
})
r.changed = *hide != before
r = core.NewChoiceResult(r.Element, *hide != before)
if was && !*open {
slog.Debug("mujica: whats_new_dialog close", "hide", *hide)
}
@@ -1,4 +1,4 @@
package mujica
package account
import (
"testing"
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
@@ -8,9 +8,12 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Workspace is a place a user can switch to.
@@ -36,7 +39,7 @@ type workspaceSwitcherState struct {
}
// WorkspaceSwitcher filters workspaces and changes the current selection.
func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts WorkspaceSwitcherOptions) ChoiceResult {
func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts WorkspaceSwitcherOptions) core.ChoiceResult {
var cur *Workspace
for i := range spaces {
if spaces[i].ID == "" || spaces[i].Name == "" {
@@ -49,7 +52,7 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
if cur == nil {
panic(fmt.Sprintf("mujica: WorkspaceSwitcher current %q is not a workspace", *current))
}
var r ChoiceResult
var r core.ChoiceResult
b := accountTrigger(c, cur.Name, cur.Detail, nil, opts.Disabled)
r.Element = b
st := ui.Local(b, "ws", func() workspaceSwitcherState { return workspaceSwitcherState{} })
@@ -61,13 +64,13 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
}
choose := func(i int) {
if !disabled && spaces[i].ID != *current {
*current, r.changed = spaces[i].ID, true
*current, r = spaces[i].ID, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: workspace_switcher switched")
}
st.open = false
}
Popover(c, b, &st.open, PopoverOptions{Width: 300, Label: msg(c, "workspace_switcher.label")}, func() {
res := SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "workspace_switcher.search"), Label: msg(c, "workspace_switcher.search")})
overlay.Popover(c, b, &st.open, overlay.PopoverOptions{Width: 300, Label: core.Msg(c, "workspace_switcher.label")}, func() {
res := input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "workspace_switcher.search"), Label: core.Msg(c, "workspace_switcher.search")})
in := res.Input
in.AutoFocus()
q := strings.ToLower(strings.TrimSpace(st.query))
@@ -94,7 +97,7 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
choose(hits[st.cursor])
}
if len(hits) == 0 {
ui.Text(c, msg(c, "workspace_switcher.none")).TextColor(Tokens(c).TextMuted).Padding(Density(c).Unit() * 2).Role(ui.RoleStatus)
ui.Text(c, core.Msg(c, "workspace_switcher.none")).TextColor(core.Tokens(c).TextMuted).Padding(core.Density(c).Unit() * 2).Role(ui.RoleStatus)
}
list := accountRows(c, len(hits), func(n int) any { return spaces[hits[n]].ID }, func(n int) {
i := hits[n]
@@ -113,8 +116,8 @@ func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts
// workspaceRow displays a clickable workspace with a selection mark.
func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
b := ui.ButtonBase(c).Key(w.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2).
Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(w.Name).Selected(current)
internal.Ring(b, k.Focus, theme.ControlRadius)
@@ -133,7 +136,7 @@ func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
ui.Text(c, w.Name).SingleLine().Ellipsis("…")
if w.Detail != "" {
ui.Text(c, w.Detail).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
ui.Text(c, w.Detail).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
}
})
})
@@ -1,4 +1,4 @@
package mujica
package account
import (
"fmt"
+18 -17
View File
@@ -1,13 +1,14 @@
package mujica
package agent
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FileChange is a proposed edit; empty Old adds a file.
@@ -59,9 +60,9 @@ func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
// agentDiffStats shows signed added and removed counts.
func agentDiffStats(c *ui.Context, add, del int) {
k := Tokens(c)
size := FontSize(c, theme.CaptionSize+1)
ui.Row(c).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(msg(c, "file_change_card.stats"), add, del)).Children(func() {
k := core.Tokens(c)
size := core.FontSize(c, theme.CaptionSize+1)
ui.Row(c).Gap(core.Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(core.Msg(c, "file_change_card.stats"), add, del)).Children(func() {
ui.Text(c, fmt.Sprintf("+%d", add)).TextColor(k.Success).FontSize(size).Font(theme.MonoFont)
ui.Text(c, fmt.Sprintf("−%d", del)).TextColor(k.Danger).FontSize(size).Font(theme.MonoFont)
})
@@ -69,8 +70,8 @@ func agentDiffStats(c *ui.Context, add, del int) {
// agentDiffRow draws one numbered, signed, tinted diff line.
func agentDiffRow(c *ui.Context, l internal.DiffLine) {
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
sign, bg, fg := " ", ui.Transparent, k.TextMuted
switch l.Op {
case internal.DiffInsert:
@@ -98,26 +99,26 @@ func agentDiffRow(c *ui.Context, l internal.DiffLine) {
// agentDiffView shows lines[from:to] in a capped well, virtualized when long.
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element {
w := agentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
return w.Padding(Density(c).Unit(), 0)
w := AgentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
return w.Padding(core.Density(c).Unit(), 0)
}
// agentDecisionMark shows a decision by icon and word.
func agentDecisionMark(c *ui.Context, d FileDecision) {
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
agentCaption(c, agentDecisionIcon(c, d))
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
AgentCaption(c, agentDecisionIcon(c, d))
})
}
// agentDecisionIcon draws a named decision icon, returning its word.
func agentDecisionIcon(c *ui.Context, d FileDecision) string {
k := Tokens(c)
tone, icon, word := k.TextMuted, "circle", msg(c, "file_change_card.pending")
k := core.Tokens(c)
tone, icon, word := k.TextMuted, "circle", core.Msg(c, "file_change_card.pending")
switch d {
case FileAccepted:
tone, icon, word = k.Success, "circle-check", msg(c, "file_change_card.accepted")
tone, icon, word = k.Success, "circle-check", core.Msg(c, "file_change_card.accepted")
case FileRejected:
tone, icon, word = k.Danger, "circle-x", msg(c, "file_change_card.rejected")
tone, icon, word = k.Danger, "circle-x", core.Msg(c, "file_change_card.rejected")
case FilePending:
default:
panic(fmt.Sprintf("mujica: unknown FileDecision %d", int(d)))
+35 -34
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"bytes"
@@ -8,9 +8,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AgentState is an agent or step state; zero is pending.
@@ -67,18 +68,18 @@ func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
// agentMark draws s; running spins unless still.
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
tone, icon := agentTone(Tokens(c), s)
tone, icon := agentTone(core.Tokens(c), s)
if s == AgentRunning && !still {
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
return core.Spinner(c, core.SpinnerOptions{Size: size, Color: tone, Label: core.Msg(c, "agent_status.running")})
}
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
}
// agentWord is the built-in word for s.
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
func agentWord(c *ui.Context, s AgentState) string { return core.Msg(c, "agent_status."+s.String()) }
// agentDuration formats d like "320 ms" or "4.2 s".
func agentDuration(d time.Duration) string {
// AgentDuration formats d like "320 ms" or "4.2 s".
func AgentDuration(d time.Duration) string {
if d < 0 {
panic(fmt.Sprintf("mujica: negative duration %v", d))
}
@@ -93,35 +94,35 @@ func agentDuration(d time.Duration) string {
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
}
// agentCard is the bordered surface every agent card sits on.
func agentCard(c *ui.Context) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
// AgentCard is the bordered surface every agent card sits on.
func AgentCard(c *ui.Context) *ui.Element {
k := core.Tokens(c)
u := core.Density(c).Unit()
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border)
}
// agentCaption is small muted text.
func agentCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
// AgentCaption is small muted text.
func AgentCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted)
}
// agentWell styles a sunken monospace well.
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
FontSize(FontSize(c, theme.CaptionSize+1))
FontSize(core.FontSize(c, theme.CaptionSize+1))
}
// agentWellRows builds n rows in a well.
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
return agentWell(c, agentRows(c, n, row), maxHeight)
// AgentWellRows builds n rows in a well.
func AgentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
return agentWell(c, AgentRows(c, n, row), maxHeight)
}
// agentRows builds n scrolling rows, virtualized when many.
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
// AgentRows builds n scrolling rows, virtualized when many.
func AgentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
if n > agentVirtualRows {
return ui.List(c, nil, n, row)
}
@@ -182,7 +183,7 @@ func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
// agentCodeLine builds one highlighted line of language.
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
k := Tokens(c)
k := core.Tokens(c)
toks, _ := internal.Highlight(language, line, inBlock)
spans := make([]ui.Span, 0, len(toks))
for _, t := range toks {
@@ -194,15 +195,15 @@ func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
ui.RichText(c, spans...)
}
// agentCode shows highlighted text in a capped well.
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
// AgentCode shows highlighted text in a capped well.
func AgentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
holder := ui.Column(c).Shrink(1)
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
(*cc).update(language, text)
lines, open := (*cc).lines, (*cc).open
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
holder.Children(func() {
agentWellRows(c, len(lines), row, maxHeight)
AgentWellRows(c, len(lines), row, maxHeight)
})
return holder
}
@@ -263,7 +264,7 @@ func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
if frac < 0 || frac > 1 || frac != frac {
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
}
k := Tokens(c)
k := core.Tokens(c)
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.Border, 2)
@@ -273,10 +274,10 @@ func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
})
}
// agentToggle is a small text button reporting a click.
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
k := Tokens(c)
u := Density(c).Unit()
// AgentToggle is a small text button reporting a click.
func AgentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
k := core.Tokens(c)
u := core.Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
internal.Ring(b, k.Focus, theme.ControlRadius)
@@ -287,7 +288,7 @@ func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
if icon != "" {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
}
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
})
return !b.IsDisabled() && b.Clicked()
}
+25 -23
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,7 +7,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PlanItem is a plan task: pending, running, done or skipped.
@@ -28,7 +30,7 @@ type AgentPlanOptions struct {
type agentPlanState struct{ draft string }
// AgentPlan is a live checklist with skip, restore and add.
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult {
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) core.ChoiceResult {
if items == nil {
panic("mujica: AgentPlan needs items")
}
@@ -49,40 +51,40 @@ func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceRe
}
done += agentB2I(it.State == AgentDone)
}
u := Density(c).Unit()
u := core.Density(c).Unit()
title := opts.Title
if title == "" {
title = msg(c, "agent_plan.title")
title = core.Msg(c, "agent_plan.title")
}
var r ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(title)
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(title)
st := ui.Local(r.Element, "mujica.plan", func() agentPlanState { return agentPlanState{} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
})
frac := 0.0
if counted > 0 {
frac = float64(done) / float64(counted)
}
agentBar(c, frac, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
list := *items
for i := range list {
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() {
if agentPlanRow(c, &list[i], locked, opts.Still) {
r.changed = true
r = core.NewChoiceResult(r.Element, true)
}
})
}
if !locked && agentPlanAdd(c, &st.draft) {
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
st.draft = ""
r.changed = true
r = core.NewChoiceResult(r.Element, true)
}
})
if r.changed {
if r.Changed() {
slog.Debug("mujica: agent_plan change", "items", len(*items))
}
return r
@@ -90,9 +92,9 @@ func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceRe
// agentPlanRow builds one item, reporting a user change.
func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
k := Tokens(c)
k := core.Tokens(c)
checked := it.State == AgentDone
box := Checkbox(c, &checked, "", CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
box := input.Checkbox(c, &checked, "", input.CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
changed := !locked && box.Changed()
if changed {
it.State = AgentPending
@@ -106,16 +108,16 @@ func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
t.Strikethrough().TextColor(k.TextMuted)
case AgentRunning:
agentMark(c, AgentRunning, 14, still)
agentCaption(c, msg(c, "agent_plan.doing"))
AgentCaption(c, core.Msg(c, "agent_plan.doing"))
}
if it.State == AgentDone || locked {
return changed
}
label, icon, next := msg(c, "agent_plan.skip"), "ban", AgentSkipped
label, icon, next := core.Msg(c, "agent_plan.skip"), "ban", AgentSkipped
if it.State == AgentSkipped {
label, icon, next = msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
label, icon, next = core.Msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
}
if agentToggle(c, label, icon, false) {
if AgentToggle(c, label, icon, false) {
it.State = next
changed = true
}
@@ -124,13 +126,13 @@ func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
// agentPlanAdd builds the new-item field and reports a non-empty submit.
func agentPlanAdd(c *ui.Context, draft *string) bool {
u := Density(c).Unit()
u := core.Density(c).Unit()
add := false
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "agent_plan.placeholder"), Label: msg(c, "agent_plan.placeholder")})
in := input.TextInput(c, draft, input.TextInputOptions{Placeholder: core.Msg(c, "agent_plan.placeholder"), Label: core.Msg(c, "agent_plan.placeholder")})
in.Element.Grow(1)
blank := strings.TrimSpace(*draft) == ""
add = Button(c, msg(c, "agent_plan.add"), ButtonOptions{Variant: Secondary, Disabled: blank}).Clicked()
add = input.Button(c, core.Msg(c, "agent_plan.add"), input.ButtonOptions{Variant: input.Secondary, Disabled: blank}).Clicked()
add = (add || in.Submitted()) && !blank
})
return add
@@ -1,9 +1,11 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func agentPlanSample() []PlanItem {
@@ -17,7 +19,7 @@ func agentPlanSample() []PlanItem {
func planTester(items *[]PlanItem, opts AgentPlanOptions, changes *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if AgentPlan(c, items, opts).Changed() {
*changes++
}
@@ -1,7 +1,10 @@
package mujica
package agent
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
)
// AgentProgressOptions configure an AgentProgress.
@@ -19,16 +22,16 @@ type AgentProgressOptions struct {
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element {
label := opts.Label
if label == "" {
label = msg(c, "agent_progress.label")
label = core.Msg(c, "agent_progress.label")
}
u := Density(c).Unit()
u := core.Density(c).Unit()
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
col.Children(func() {
Progress(c, value, ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
feedback.Progress(c, value, feedback.ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
if opts.Action != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(opts.Action).Children(func() {
agentMark(c, AgentRunning, 14, opts.Still)
agentCaption(c, opts.Action)
AgentCaption(c, opts.Action)
})
}
})
@@ -1,15 +1,17 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestAgentProgressValueAndAction(t *testing.T) {
value, action := 0.25, "Reading ledger.md"
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
AgentProgress(c, value, AgentProgressOptions{Action: action, Still: true})
}, 400, 200)
if !tt.HasText("25%") || !tt.HasText("Reading ledger.md") || !tt.HasText("Progress") {
@@ -24,7 +26,7 @@ func TestAgentProgressValueAndAction(t *testing.T) {
func TestAgentProgressIndeterminate(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
AgentProgress(c, 0, AgentProgressOptions{Label: "Research", Indeterminate: true, Still: true})
}, 400, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
@@ -40,7 +42,7 @@ func TestAgentProgressRejectsOutOfRange(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
AgentProgress(c, 1.5, AgentProgressOptions{})
}, 200, 100)
}
+6 -5
View File
@@ -1,9 +1,10 @@
package mujica
package agent
import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AgentStatusOptions configure an AgentStatus.
@@ -20,15 +21,15 @@ func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.E
if label == "" {
label = agentWord(c, state) // panics on an unknown state
}
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
tone, _ := agentTone(k, state)
pill := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*1.5).Padding(u, u*2.5).
Radius(theme.CardRadius).Border(theme.BorderWidth, tone).Background(k.Surface).
Role(ui.RoleStatus).Label(label)
pill.Children(func() {
agentMark(c, state, 14, opts.Still)
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
})
return pill
}
@@ -1,16 +1,18 @@
package mujica
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestAgentStatusWords(t *testing.T) {
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
for _, s := range states {
AgentStatus(c, s, AgentStatusOptions{Still: true})
}
@@ -26,7 +28,7 @@ func TestAgentStatusWords(t *testing.T) {
func TestAgentStatusAnimationStopsWhenStill(t *testing.T) {
still := false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
AgentStatus(c, AgentRunning, AgentStatusOptions{Still: still})
}, 300, 100)
tt.Frame()
@@ -53,7 +55,7 @@ func TestAgentStatusRejectsUnknownState(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
AgentStatus(c, AgentState(42), AgentStatusOptions{})
}, 200, 100)
}
+11 -10
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,9 +7,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AgentStep is one step of an agent run.
@@ -78,8 +79,8 @@ func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions)
// agentStepRow builds one step, reporting a header click.
func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still bool, r *AgentStepListResult) bool {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
clicked := false
top := (d.ControlHeight() - 18) / 2 // centers the mark on the step header
@@ -109,11 +110,11 @@ func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still boo
head.Children(func() {
ui.Text(c, s.Title).FontWeight(600).Grow(1).Shrink(1)
if current {
agentCaption(c, msg(c, "agent_step_list.current"))
AgentCaption(c, core.Msg(c, "agent_step_list.current"))
}
agentCaption(c, agentWord(c, s.State))
AgentCaption(c, agentWord(c, s.State))
if s.State == AgentDone || s.State == AgentFailed {
agentCaption(c, agentDuration(s.Duration))
AgentCaption(c, AgentDuration(s.Duration))
}
if s.Detail != "" {
chev := "chevron-right"
@@ -129,7 +130,7 @@ func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still boo
}
if s.State == AgentFailed {
ui.Row(c).Children(func() {
if agentToggle(c, msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
if AgentToggle(c, core.Msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
r.Retry = s.ID
}
})
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var agentStepsSample = []AgentStep{
@@ -16,7 +18,7 @@ var agentStepsSample = []AgentStep{
func stepTester(steps []AgentStep, opts AgentStepListOptions, got *[]AgentStepListResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := AgentStepList(c, steps, opts)
if r.Retry != "" || r.Toggled != "" {
*got = append(*got, r)
+19 -18
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,9 +7,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ArtifactKind is what an artifact holds; zero is code.
@@ -71,35 +72,35 @@ func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) Artif
if h == 0 {
h = 320
}
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
var r ArtifactPanelResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(art.Title)
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(art.Title)
st := ui.Local(r.Element, "mujica.artifact", func() artifactPanelState { return artifactPanelState{source: art.Kind == ArtifactCode} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
if opts.Preview != nil {
artifactViewSwitch(c, &st.source, locked)
}
copied := !st.copiedAt.IsZero() && c.Now().Sub(st.copiedAt) < 2*time.Second
label, icon := msg(c, "artifact_panel.copy"), "copy"
label, icon := core.Msg(c, "artifact_panel.copy"), "copy"
if copied {
label, icon = msg(c, "artifact_panel.copied"), "check"
label, icon = core.Msg(c, "artifact_panel.copied"), "check"
c.After(2*time.Second - c.Now().Sub(st.copiedAt))
}
if agentToggle(c, label, icon, locked) {
if AgentToggle(c, label, icon, locked) {
c.WriteClipboard(art.Content)
st.copiedAt, r.Copied = c.Now(), true
}
if agentToggle(c, msg(c, "artifact_panel.export"), "upload", locked) {
if AgentToggle(c, core.Msg(c, "artifact_panel.export"), "upload", locked) {
r.Exported = true
}
})
ui.Column(c).Height(h).Shrink(0).Children(func() {
if st.source || opts.Preview == nil {
agentCode(c, art.Language, art.Content, h).Grow(1)
AgentCode(c, art.Language, art.Content, h).Grow(1)
return
}
ui.Scroll(c).Grow(1).Padding(u*3).Radius(theme.ControlRadius).Background(k.Background).
@@ -114,14 +115,14 @@ func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) Artif
// artifactViewSwitch is the Preview / Source segmented switch.
func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
ui.Row(c).Shrink(0).FocusGroup(ui.Horizontal).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.ControlRadius).
Role(ui.RoleRadioGroup).Children(func() {
for _, src := range []bool{false, true} {
label, icon := msg(c, "artifact_panel.preview"), "eye"
label, icon := core.Msg(c, "artifact_panel.preview"), "eye"
if src {
label, icon = msg(c, "artifact_panel.source"), "file-text"
label, icon = core.Msg(c, "artifact_panel.source"), "file-text"
}
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Role(ui.RoleRadio).Label(label).
Disabled(locked)
@@ -137,7 +138,7 @@ func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
ui.Icon(c, icons.Must("check")).Size(12, 12).TextColor(k.AccentText)
}
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1))
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1))
})
if !b.IsDisabled() && b.Clicked() && !on {
*source = src
@@ -1,16 +1,18 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var artifactCodeSample = Artifact{Title: "seating.go", Language: "go", Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1}\n}\n"}
func artifactTester(art Artifact, opts ArtifactPanelOptions, got *[]ArtifactPanelResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := ArtifactPanel(c, art, opts)
if r.Copied || r.Exported {
*got = append(*got, r)
@@ -37,7 +39,7 @@ func TestArtifactPanelCodeCopyExport(t *testing.T) {
func TestArtifactPanelPreviewSourceSwitch(t *testing.T) {
doc := Artifact{Title: "Invitation", Kind: ArtifactDocument, Content: "# You are invited\n"}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ArtifactPanel(c, doc, ArtifactPanelOptions{Preview: func() { ui.Text(c, "Rendered invitation") }})
}, 600, 460)
if !tt.HasText("Rendered invitation") || tt.HasText("# You are invited") {
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,7 +6,8 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
// ArtifactVersion is one saved version of an artifact.
@@ -39,11 +40,11 @@ func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVer
panic(fmt.Sprintf("mujica: ArtifactVersionSwitcher version %d needs a Label", i))
}
}
u := Density(c).Unit()
u := core.Density(c).Unit()
n := len(versions)
var r ArtifactVersionResult
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled).
Label(msg(c, "artifact_version_switcher.label"))
Label(core.Msg(c, "artifact_version_switcher.label"))
locked := r.Element.IsDisabled()
move := func(d int) {
if to := *current + d; !locked && to >= 0 && to < n {
@@ -51,25 +52,25 @@ func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVer
}
}
r.Element.Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
move(-1)
}
v := versions[*current]
ui.Column(c).Shrink(1).Role(ui.RoleStatus).Label(v.Label).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, v.Label).FontWeight(600).SingleLine()
agentCaption(c, fmt.Sprintf(msg(c, "artifact_version_switcher.count"), *current+1, n))
AgentCaption(c, fmt.Sprintf(core.Msg(c, "artifact_version_switcher.count"), *current+1, n))
})
if v.Note != "" {
agentCaption(c, v.Note).SingleLine()
AgentCaption(c, v.Note).SingleLine()
}
})
if iconAction(c, icons.Must("chevron-right"), msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
move(1)
}
if *current > 0 {
label := fmt.Sprintf(msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
if agentToggle(c, label, "arrow-up-down", locked) {
label := fmt.Sprintf(core.Msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
if AgentToggle(c, label, "arrow-up-down", locked) {
r.Compare = true
}
}
@@ -1,9 +1,11 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var artifactVersionsSample = []ArtifactVersion{
@@ -14,7 +16,7 @@ var artifactVersionsSample = []ArtifactVersion{
func versionTester(cur *int, opts ArtifactVersionSwitcherOptions, got *[]ArtifactVersionResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := ArtifactVersionSwitcher(c, cur, artifactVersionsSample, opts)
if r.Changed || r.Compare {
*got = append(*got, r)
+19 -17
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,8 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Checkpoint is a saved point to roll back to.
@@ -30,7 +32,7 @@ type checkpointListState struct {
}
// CheckpointList marks the current point and confirms rollbacks.
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult {
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) core.ChoiceResult {
if current == nil {
panic("mujica: CheckpointList needs current")
}
@@ -48,9 +50,9 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
if maxH == 0 {
maxH = 360
}
k := Tokens(c)
var r ChoiceResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "checkpoint_list.label"))
k := core.Tokens(c)
var r core.ChoiceResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "checkpoint_list.label"))
st := ui.Local(r.Element, "mujica.checkpoints", func() checkpointListState { return checkpointListState{} })
locked := r.Element.IsDisabled()
if locked {
@@ -58,10 +60,10 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
}
r.Element.Children(func() {
if len(points) == 0 {
agentCaption(c, msg(c, "checkpoint_list.empty"))
AgentCaption(c, core.Msg(c, "checkpoint_list.empty"))
return
}
agentRows(c, len(points), func(i int) {
AgentRows(c, len(points), func(i int) {
if checkpointRow(c, points[i], points[i].ID == *current, locked) {
st.asking, st.target = true, points[i].ID
}
@@ -73,10 +75,10 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
label = p.Label
}
}
res := AlertDialog(c, &st.asking, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "checkpoint_list.confirm"), label),
Description: msg(c, "checkpoint_list.warning"), ConfirmLabel: msg(c, "checkpoint_list.rollback")})
res := overlay.AlertDialog(c, &st.asking, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "checkpoint_list.confirm"), label),
Description: core.Msg(c, "checkpoint_list.warning"), ConfirmLabel: core.Msg(c, "checkpoint_list.rollback")})
if res.Confirmed && !locked && seen[st.target] {
*current, r.changed = st.target, true
*current, r = st.target, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: checkpoint_list rollback", "to", st.target)
}
})
@@ -85,8 +87,8 @@ func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts Ch
// checkpointRow shows one checkpoint and reports a rollback request.
func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
asked := false
row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
@@ -105,14 +107,14 @@ func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
ui.Text(c, p.Label).FontWeight(600).SingleLine()
if p.Note != "" {
agentCaption(c, p.Note)
AgentCaption(c, p.Note)
}
})
if current {
agentCaption(c, msg(c, "checkpoint_list.current"))
AgentCaption(c, core.Msg(c, "checkpoint_list.current"))
return
}
asked = agentToggle(c, msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
asked = AgentToggle(c, core.Msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
})
row.Label(p.Label)
return asked
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var checkpointsSample = []Checkpoint{
@@ -15,7 +17,7 @@ var checkpointsSample = []Checkpoint{
func checkpointTester(cur *string, points []Checkpoint, opts CheckpointListOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if CheckpointList(c, cur, points, opts).Changed() {
*changes++
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -8,9 +8,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CommandRun is a command run; the caller appends Output.
@@ -57,7 +59,7 @@ func (o *commandOutput) update(out string) {
}
// CommandExecutionCard shows a command run; Changed requests an abort.
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult {
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) core.ChoiceResult {
if run.Command == "" {
panic("mujica: CommandExecutionCard needs a Command")
}
@@ -68,10 +70,10 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
if h == 0 {
h = 240
}
k := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(run.Command)
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(run.Command)
out := ui.Local(r.Element, "mujica.cmd.output", func() *commandOutput { return &commandOutput{} })
o := *out
o.list.FollowEnd = true
@@ -82,32 +84,32 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
ui.Text(c, run.Command).Font(theme.MonoFont).FontWeight(600).Grow(1).Shrink(1).Selectable()
if run.Running {
agentMark(c, AgentRunning, 16, opts.Still)
agentCaption(c, msg(c, "agent_status.running"))
AgentCaption(c, core.Msg(c, "agent_status.running"))
locked := r.Element.IsDisabled()
if Button(c, msg(c, "command_execution_card.abort"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() && !locked {
r.changed = true
if input.Button(c, core.Msg(c, "command_execution_card.abort"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() && !locked {
r = core.NewChoiceResult(r.Element, true)
}
return
}
commandExit(c, run.ExitCode)
agentCaption(c, agentDuration(run.Duration))
AgentCaption(c, AgentDuration(run.Duration))
})
if run.Dir != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("folder")).Size(14, 14).TextColor(k.TextMuted)
agentCaption(c, run.Dir).Font(theme.MonoFont)
AgentCaption(c, run.Dir).Font(theme.MonoFont)
})
}
well := ui.List(c, &o.list, len(o.lines), func(i int) {
ui.RichText(c, agentAnsiSpans(k, o.lines[i])...)
})
agentWell(c, well, h).Height(h).Label(msg(c, "command_execution_card.output")).Children(func() {
agentWell(c, well, h).Height(h).Label(core.Msg(c, "command_execution_card.output")).Children(func() {
if len(o.lines) == 0 {
agentCaption(c, msg(c, "command_execution_card.no_output"))
AgentCaption(c, core.Msg(c, "command_execution_card.no_output"))
}
})
})
if r.changed {
if r.Changed() {
slog.Debug("mujica: command_execution_card abort", "command", run.Command)
}
return r
@@ -115,14 +117,14 @@ func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCa
// commandExit shows the exit code by icon and word.
func commandExit(c *ui.Context, code int) {
k := Tokens(c)
k := core.Tokens(c)
tone, icon := k.Success, "circle-check"
if code != 0 {
tone, icon = k.Danger, "circle-x"
}
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
ui.Text(c, fmt.Sprintf(msg(c, "command_execution_card.exit"), code)).TextColor(tone).
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
ui.Text(c, fmt.Sprintf(core.Msg(c, "command_execution_card.exit"), code)).TextColor(tone).
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
})
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,12 +7,14 @@ import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if CommandExecutionCard(c, *run, opts).Changed() {
*aborts++
}
+2
View File
@@ -0,0 +1,2 @@
// Package agent holds MujicaUI agent plans, tool calls and approvals.
package agent
+244
View File
@@ -0,0 +1,244 @@
package agent_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/agent"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func ExampleToolCallCard() {
call := agent.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
Result: "{\"lines\":40}", State: agent.AgentDone, Duration: 420 * time.Millisecond}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ToolCallCard(c, call, agent.ToolCallCardOptions{OpenArgs: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolCallGroup() {
open := false
calls := []agent.ToolCall{
{ID: "a", Name: "list_dir", State: agent.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: agent.AgentFailed, Error: "not found"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ToolCallGroup(c, &open, calls, agent.ToolCallGroupOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolApprovalDialog() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.ToolApprovalDialog(c, &open, agent.ToolApprovalOptions{
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
})
if r.Allowed {
fmt.Println("run the tool")
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePermissionPrompt() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.PermissionPrompt(c, &open, agent.PermissionPromptOptions{
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
})
if r.Choice == agent.PermissionAllowAlways {
fmt.Println("remember the grant")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStepList() {
steps := []agent.AgentStep{
{ID: "read", Title: "Read the guest list", State: agent.AgentDone, Duration: time.Second},
{ID: "seat", Title: "Assign the seats", State: agent.AgentFailed, Detail: "Table 4 is full."},
{ID: "send", Title: "Send invitations", State: agent.AgentPending},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.AgentStepList(c, steps, agent.AgentStepListOptions{}); r.Retry != "" {
fmt.Println("retry", r.Retry)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentPlan() {
items := []agent.PlanItem{
{ID: "a", Text: "Read the guest list", State: agent.AgentDone},
{ID: "b", Text: "Assign the seats", State: agent.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentPlan(c, &items, agent.AgentPlanOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStatus() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentStatus(c, agent.AgentWaiting, agent.AgentStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentProgress() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentProgress(c, 0.4, agent.AgentProgressOptions{Action: "Reading ledger.md"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSubAgentTree() {
agents := []agent.SubAgent{{ID: "lead", Name: "Steward", State: agent.AgentRunning, Children: []agent.SubAgent{
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: agent.AgentWaiting},
}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.SubAgentTree(c, agents, agent.SubAgentTreeOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileChangeCard() {
decision := agent.FilePending
change := agent.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.FileChangeCard(c, &decision, change, agent.FileChangeCardOptions{}).OpenDiff {
fmt.Println("open the full diff")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMultiFileDiffReview() {
files := []agent.FileChange{
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
}
decisions := make([]agent.FileDecision, len(files))
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.MultiFileDiffReview(c, &decisions, files, agent.MultiFileDiffReviewOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommandExecutionCard() {
run := agent.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.CommandExecutionCard(c, run, agent.CommandExecutionCardOptions{}).Changed() {
fmt.Println("abort the process")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleScreenshotStream() {
var st agent.ScreenshotStreamState
var frames []agent.ScreenshotFrame // appended as the agent captures them
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ScreenshotStream(c, &st, frames, agent.ScreenshotStreamOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactPanel() {
art := agent.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.ArtifactPanel(c, art, agent.ArtifactPanelOptions{}).Exported {
fmt.Println("save the file")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactVersionSwitcher() {
current := 1
versions := []agent.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.ArtifactVersionSwitcher(c, &current, versions, agent.ArtifactVersionSwitcherOptions{}).Compare {
fmt.Println("diff v1 and v2")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSandboxStatus() {
info := agent.SandboxInfo{Name: "sandbox-7", State: agent.SandboxRunning,
Resources: []agent.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.SandboxStatus(c, info, agent.SandboxStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMCPServerList() {
servers := []agent.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: agent.MCPConnected, Tools: 12}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.MCPServerList(c, servers, agent.MCPServerListOptions{}); r.Action == agent.MCPStop {
fmt.Println("stop", r.Server)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMemoryPanel() {
entries := []agent.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.MemoryPanel(c, &entries, agent.MemoryPanelOptions{}); r.Deleted != "" {
fmt.Println("forget", r.Deleted)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCheckpointList() {
current := "c2"
points := []agent.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.CheckpointList(c, &current, points, agent.CheckpointListOptions{}).Changed() {
fmt.Println("restore", current)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleHumanInputRequest() {
answer := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.HumanInputRequest(c, &answer, agent.HumanInputRequestOptions{
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
})
if r.Submitted {
fmt.Println("reply", answer)
}
}
ui.Render(view, 400, 300, 1)
}
@@ -1,12 +1,14 @@
package mujica
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FileChangeCardOptions configure a FileChangeCard.
@@ -37,20 +39,20 @@ func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, op
if preview == 0 {
preview = 8
}
u := Density(c).Unit()
u := core.Density(c).Unit()
var r FileChangeResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(change.Path)
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(change.Path)
dc := agentDiffOf(r.Element, change)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(Tokens(c).TextMuted).Shrink(0)
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(core.Tokens(c).TextMuted).Shrink(0)
ui.Text(c, change.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
switch {
case change.Old == "" && change.New != "":
agentCaption(c, msg(c, "file_change_card.new"))
AgentCaption(c, core.Msg(c, "file_change_card.new"))
case change.New == "" && change.Old != "":
agentCaption(c, msg(c, "file_change_card.deleted"))
AgentCaption(c, core.Msg(c, "file_change_card.deleted"))
}
agentDiffStats(c, dc.add, dc.del)
agentDecisionMark(c, *decision)
@@ -61,19 +63,19 @@ func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, op
agentDiffView(c, shown, 320).Font(theme.MonoFont)
}
ui.Row(c).Gap(u * 2).Justify(ui.End).Wrap().Children(func() {
if Button(c, msg(c, "file_change_card.open"), ButtonOptions{Variant: Ghost, Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.open"), input.ButtonOptions{Variant: input.Ghost, Disabled: locked}).Clicked() && !locked {
r.OpenDiff = true
}
if *decision != FilePending {
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FilePending, true
}
return
}
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FileRejected, true
}
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FileAccepted, true
}
})
@@ -1,9 +1,11 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var fileChangeSample = FileChange{Path: "court/ledger.md",
@@ -12,7 +14,7 @@ var fileChangeSample = FileChange{Path: "court/ledger.md",
func fileChangeTester(d *FileDecision, ch FileChange, opts FileChangeCardOptions, got *[]FileChangeResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := FileChangeCard(c, d, ch, opts)
if r.Decided || r.OpenDiff {
*got = append(*got, r)
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
// snapshotAfter is snapshot with drive sending input before the capture.
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -9,8 +9,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// HumanInputRequestOptions configure a HumanInputRequest. Question is required.
@@ -45,10 +47,10 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
if len(opts.Options) == 0 && !opts.AllowFree {
panic("mujica: HumanInputRequest needs Options or AllowFree")
}
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
var r HumanInputResult
r.Element = agentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
r.Element = AgentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} })
left := time.Duration(0)
if !opts.Deadline.IsZero() {
@@ -65,19 +67,19 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1)
})
if len(opts.Options) > 0 {
items := make([]RadioItem[string], len(opts.Options))
items := make([]input.RadioItem[string], len(opts.Options))
for i, o := range opts.Options {
items[i] = RadioItem[string]{Value: o, Label: o}
items[i] = input.RadioItem[string]{Value: o, Label: o}
}
chosen := *answer
if RadioGroup(c, &chosen, items, RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
if input.RadioGroup(c, &chosen, items, input.RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
*answer, st.draft = chosen, ""
}
}
submit := false
if opts.AllowFree {
in := TextInput(c, &st.draft, TextInputOptions{Placeholder: msg(c, "human_input_request.free"),
Label: msg(c, "human_input_request.free"), Disabled: locked})
in := input.TextInput(c, &st.draft, input.TextInputOptions{Placeholder: core.Msg(c, "human_input_request.free"),
Label: core.Msg(c, "human_input_request.free"), Disabled: locked})
if in.Changed() && !locked {
*answer = st.draft
}
@@ -86,7 +88,7 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
humanInputClock(c, opts.Deadline, left, st.expired)
blank := strings.TrimSpace(*answer) == ""
if Button(c, msg(c, "human_input_request.send"), ButtonOptions{Disabled: locked || blank}).Clicked() {
if input.Button(c, core.Msg(c, "human_input_request.send"), input.ButtonOptions{Disabled: locked || blank}).Clicked() {
submit = true
}
r.Submitted = submit && !locked && !blank
@@ -103,24 +105,24 @@ func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOpti
// humanInputClock shows the time left, or that it ran out.
func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) {
k := Tokens(c)
k := core.Tokens(c)
text, tone := "", k.TextMuted
switch {
case expired:
text, tone = msg(c, "human_input_request.timeout"), k.Warning
text, tone = core.Msg(c, "human_input_request.timeout"), k.Warning
case !deadline.IsZero():
text = fmt.Sprintf(msg(c, "human_input_request.left"), agentDuration(left.Truncate(time.Second)))
text = fmt.Sprintf(core.Msg(c, "human_input_request.left"), AgentDuration(left.Truncate(time.Second)))
wait := left % time.Second
if wait == 0 {
wait = time.Second
}
c.After(wait)
}
ui.Row(c).Grow(1).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
ui.Row(c).Grow(1).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
if text == "" {
return
}
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone)
ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1))
ui.Text(c, text).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1))
})
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"strings"
@@ -6,11 +6,13 @@ import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := HumanInputRequest(c, answer, *opts)
if r.Submitted || r.TimedOut {
*got = append(*got, r)
+19 -17
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,8 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MCPState is a server connection; zero is stopped.
@@ -74,15 +76,15 @@ func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions
maxH = 360
}
var r MCPServerListResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "mcp_server_list.label"))
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "mcp_server_list.label"))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
if len(servers) == 0 {
agentCaption(c, msg(c, "mcp_server_list.empty"))
AgentCaption(c, core.Msg(c, "mcp_server_list.empty"))
return
}
k := Tokens(c)
agentRows(c, len(servers), func(i int) {
k := core.Tokens(c)
AgentRows(c, len(servers), func(i int) {
if a := mcpServerRow(c, servers[i], locked, opts.Still); a != MCPNone && !locked {
r.Action, r.Server = a, servers[i].ID
}
@@ -97,11 +99,11 @@ func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions
// mcpServerRow builds one server, returning its pressed action.
func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
words := [...]string{"stopped", "connecting", "connected", "error"}
word := msg(c, "mcp_server_list."+words[s.State])
word := core.Msg(c, "mcp_server_list."+words[s.State])
act := MCPNone
ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
Label(s.Name + ", " + word).Children(func() {
@@ -118,25 +120,25 @@ func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, s.Name).FontWeight(600).SingleLine()
agentCaption(c, s.Transport).Font(theme.MonoFont)
AgentCaption(c, s.Transport).Font(theme.MonoFont)
})
line := word
if s.State == MCPConnected {
line += " · " + fmt.Sprintf(msg(c, "mcp_server_list.tools"), s.Tools)
line += " · " + fmt.Sprintf(core.Msg(c, "mcp_server_list.tools"), s.Tools)
}
agentCaption(c, line)
AgentCaption(c, line)
if s.State == MCPError && s.Error != "" {
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Selectable()
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Selectable()
}
})
if s.State == MCPError && Button(c, msg(c, "mcp_server_list.reconnect"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() {
if s.State == MCPError && input.Button(c, core.Msg(c, "mcp_server_list.reconnect"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() {
act = MCPReconnect
}
if s.State == MCPStopped {
if Button(c, msg(c, "mcp_server_list.start"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
if input.Button(c, core.Msg(c, "mcp_server_list.start"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
act = MCPStart
}
} else if Button(c, msg(c, "mcp_server_list.stop"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
} else if input.Button(c, core.Msg(c, "mcp_server_list.stop"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
act = MCPStop
}
})
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var mcpServersSample = []MCPServer{
@@ -17,7 +19,7 @@ var mcpServersSample = []MCPServer{
func mcpTester(servers []MCPServer, opts MCPServerListOptions, got *[]MCPServerListResult) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := MCPServerList(c, servers, opts)
if r.Action != MCPNone {
*got = append(*got, r)
+22 -19
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,7 +7,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MemoryEntry is one memory; the caller persists it.
@@ -53,26 +56,26 @@ func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions)
if maxH == 0 {
maxH = 360
}
u := Density(c).Unit()
u := core.Density(c).Unit()
var r MemoryPanelResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "memory_panel.title"))
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "memory_panel.title"))
st := ui.Local(r.Element, "mujica.memory", func() memoryPanelState { return memoryPanelState{} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "memory_panel.count"), len(*entries)))
ui.Text(c, core.Msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "memory_panel.count"), len(*entries)))
})
SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "memory_panel.search"), Disabled: locked})
input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "memory_panel.search"), Disabled: locked})
memoryFilter(st, *entries)
if len(st.shown) == 0 {
agentCaption(c, msg(c, "memory_panel.empty"))
AgentCaption(c, core.Msg(c, "memory_panel.empty"))
return
}
agentRows(c, len(st.shown), func(i int) {
AgentRows(c, len(st.shown), func(i int) {
idx := st.shown[i]
ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) })
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, Tokens(c).Border)
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, core.Tokens(c).Border)
})
if r.Deleted != "" {
for i, e := range *entries {
@@ -117,16 +120,16 @@ func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelSt
if e.ID == "" {
panic(fmt.Sprintf("mujica: MemoryPanel entry %d needs an ID", idx))
}
u := Density(c).Unit()
u := core.Density(c).Unit()
ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() {
if st.editing == e.ID && !locked {
TextArea(c, &st.draft, TextAreaOptions{Label: msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
input.TextArea(c, &st.draft, input.TextAreaOptions{Label: core.Msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if Button(c, msg(c, "memory_panel.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
if input.Button(c, core.Msg(c, "memory_panel.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
st.editing = ""
}
blank := strings.TrimSpace(st.draft) == ""
if Button(c, msg(c, "memory_panel.save"), ButtonOptions{Disabled: blank}).Clicked() && !blank {
if input.Button(c, core.Msg(c, "memory_panel.save"), input.ButtonOptions{Disabled: blank}).Clicked() && !blank {
e.Text, st.editing, st.key = strings.TrimSpace(st.draft), "", ""
r.Edited = e.ID
}
@@ -135,19 +138,19 @@ func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelSt
}
ui.Text(c, e.Text).Selectable()
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
agentCaption(c, e.Source).Grow(1)
if agentToggle(c, msg(c, "memory_panel.edit"), "", locked) {
AgentCaption(c, e.Source).Grow(1)
if AgentToggle(c, core.Msg(c, "memory_panel.edit"), "", locked) {
st.editing, st.draft = e.ID, e.Text
}
del := ui.Row(c)
del.Children(func() {
if agentToggle(c, msg(c, "memory_panel.delete"), "trash-2", locked) {
if AgentToggle(c, core.Msg(c, "memory_panel.delete"), "trash-2", locked) {
st.confirm = e.ID
}
})
open := st.confirm == e.ID && !locked
res := Popconfirm(c, del, &open, PopconfirmOptions{Title: msg(c, "memory_panel.confirm"), Description: e.Text,
ConfirmLabel: msg(c, "memory_panel.delete"), Danger: true})
res := overlay.Popconfirm(c, del, &open, overlay.PopconfirmOptions{Title: core.Msg(c, "memory_panel.confirm"), Description: e.Text,
ConfirmLabel: core.Msg(c, "memory_panel.delete"), Danger: true})
if st.confirm == e.ID && !open {
st.confirm = ""
}
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func memorySample() []MemoryEntry {
@@ -17,7 +19,7 @@ func memorySample() []MemoryEntry {
func memoryTester(entries *[]MemoryEntry, opts MemoryPanelOptions, got *[]MemoryPanelResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
r := MemoryPanel(c, entries, opts)
if r.Edited != "" || r.Deleted != "" {
*got = append(*got, r)
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,8 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MultiFileDiffReviewOptions configure a MultiFileDiffReview.
@@ -20,7 +22,7 @@ type MultiFileDiffReviewOptions struct {
type multiFileDiffState struct{ selected int }
// MultiFileDiffReview reviews many file changes, one diff at a time.
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult {
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) core.ChoiceResult {
if decisions == nil || len(*decisions) != len(files) {
panic("mujica: MultiFileDiffReview needs one decision per file")
}
@@ -40,34 +42,34 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
if h == 0 {
h = 420
}
k := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = agentCard(c).Height(h).Disabled(opts.Disabled).Label(msg(c, "multi_file_diff_review.title"))
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Height(h).Disabled(opts.Disabled).Label(core.Msg(c, "multi_file_diff_review.title"))
st := ui.Local(r.Element, "mujica.mfdr", func() multiFileDiffState { return multiFileDiffState{} })
st.selected = max(0, min(st.selected, len(files)-1))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
ui.Text(c, msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
agentCaption(c, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
ui.Text(c, core.Msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
all := reviewed < len(files)
if Button(c, msg(c, "multi_file_diff_review.accept_all"), ButtonOptions{Variant: Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
if input.Button(c, core.Msg(c, "multi_file_diff_review.accept_all"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
for i := range *decisions {
if (*decisions)[i] == FilePending {
(*decisions)[i] = FileAccepted
}
}
r.changed = true
r = core.NewChoiceResult(r.Element, true)
}
})
frac := 0.0
if len(files) > 0 {
frac = float64(reviewed) / float64(len(files))
}
agentBar(c, frac, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
if len(files) == 0 {
agentCaption(c, msg(c, "multi_file_diff_review.empty"))
AgentCaption(c, core.Msg(c, "multi_file_diff_review.empty"))
return
}
ui.Row(c).Grow(1).Shrink(1).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
@@ -92,12 +94,12 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
ui.Column(c).Grow(1).Shrink(1).Gap(u * 2).Children(func() {
i := st.selected
if fileChangeDecide(c, &(*decisions)[i], files[i], locked) {
r.changed = true
r = core.NewChoiceResult(r.Element, true)
}
})
})
})
if r.changed {
if r.Changed() {
slog.Debug("mujica: multi_file_diff_review decide", "reviewed", reviewed)
}
return r
@@ -105,9 +107,9 @@ func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileC
// multiFileRow is one listed file; it reports a choice.
func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool {
k := Tokens(c)
u := Density(c).Unit()
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(Density(c).ControlHeight()).
k := core.Tokens(c)
u := core.Density(c).Unit()
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(core.Density(c).ControlHeight()).
Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path)
internal.Ring(row, k.Focus, theme.ControlRadius)
dc := agentDiffOf(row, f)
@@ -121,7 +123,7 @@ func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bo
}
row.Children(func() {
agentDecisionIcon(c, d)
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
agentDiffStats(c, dc.add, dc.del)
})
return row.Clicked()
@@ -129,7 +131,7 @@ func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bo
// fileChangeDecide shows a full diff and its decision buttons.
func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool {
u := Density(c).Unit()
u := core.Density(c).Unit()
changed := false
holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2)
dc := agentDiffOf(holder, f)
@@ -141,15 +143,15 @@ func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool)
agentDiffView(c, dc.lines, 1e5).Grow(1).Font(theme.MonoFont)
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if *d != FilePending {
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FilePending, true
}
return
}
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FileRejected, true
}
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
*d, changed = FileAccepted, true
}
})
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func reviewFiles() []FileChange {
@@ -17,7 +19,7 @@ func reviewFiles() []FileChange {
func reviewTester(d *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ui.Column(c).Padding(8).Children(func() {
if MultiFileDiffReview(c, d, files, opts).Changed() {
*changes++
@@ -1,12 +1,15 @@
package mujica
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PermissionChoice answers a PermissionPrompt; zero is no answer.
@@ -40,18 +43,18 @@ func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) P
}
var r PermissionResult
was := *open
k := Tokens(c)
u := Density(c).Unit()
r.Element = Dialog(c, open, DialogOptions{
k := core.Tokens(c)
u := core.Density(c).Unit()
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true,
Actions: func() {
if Button(c, msg(c, "permission_prompt.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
if input.Button(c, core.Msg(c, "permission_prompt.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() {
r.Choice = PermissionDeny
}
if Button(c, msg(c, "permission_prompt.always"), ButtonOptions{Variant: Outline}).Clicked() {
if input.Button(c, core.Msg(c, "permission_prompt.always"), input.ButtonOptions{Variant: input.Outline}).Clicked() {
r.Choice = PermissionAllowAlways
}
if Button(c, msg(c, "permission_prompt.once"), ButtonOptions{}).Clicked() {
if input.Button(c, core.Msg(c, "permission_prompt.once"), input.ButtonOptions{}).Clicked() {
r.Choice = PermissionAllowOnce
}
},
@@ -60,7 +63,7 @@ func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) P
Border(1, k.Border).Children(func() {
ui.Icon(c, icons.Must("lock")).Size(16, 16).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
ui.Column(c).Gap(u / 2).Shrink(1).Children(func() {
agentCaption(c, msg(c, "permission_prompt.scope"))
AgentCaption(c, core.Msg(c, "permission_prompt.scope"))
ui.Text(c, opts.Scope)
})
})
@@ -1,15 +1,18 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Ask", ButtonOptions{}).Clicked() {
core.Use(c, core.Settings{})
if input.Button(c, "Ask", input.ButtonOptions{}).Clicked() {
*open = true
}
r := PermissionPrompt(c, open, PermissionPromptOptions{Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger"})
@@ -64,7 +67,7 @@ func TestPermissionPromptNeedsScope(t *testing.T) {
}()
open := true
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "x"})
}, 200, 200)
}
+17 -16
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,8 +7,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SandboxState is a sandbox's lifecycle. The zero value is SandboxStarting.
@@ -55,10 +56,10 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
panic(fmt.Sprintf("mujica: SandboxStatus resource %q needs a name and 0 <= Used <= Limit", r.Name))
}
}
k := Tokens(c)
u := Density(c).Unit()
word := msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
card := agentCard(c).Label(info.Name + ", " + word)
k := core.Tokens(c)
u := core.Density(c).Unit()
word := core.Msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
card := AgentCard(c).Label(info.Name + ", " + word)
card.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, info.Name).FontWeight(600).SingleLine().Grow(1).Shrink(1)
@@ -68,21 +69,21 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
frac := r.Used / r.Limit
ui.Column(c).Gap(u).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
agentCaption(c, r.Name).Grow(1)
AgentCaption(c, r.Name).Grow(1)
if frac >= 0.9 {
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(msg(c, "sandbox_status.high"))
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(core.Msg(c, "sandbox_status.high"))
}
ui.Text(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Limit), r.Unit)).
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
ui.Text(c, fmt.Sprintf("%s / %s %s", SandboxNum(r.Used), SandboxNum(r.Limit), r.Unit)).
FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
})
agentBar(c, frac, fmt.Sprintf("%s %.0f%%", r.Name, frac*100))
})
}
if !info.Expires.IsZero() && info.State != SandboxStopped && info.State != SandboxFailed {
left := info.Expires.Sub(c.Now())
text := msg(c, "sandbox_status.expired")
text := core.Msg(c, "sandbox_status.expired")
if left > 0 {
text = fmt.Sprintf(msg(c, "sandbox_status.left"), agentDuration(left.Truncate(time.Second)))
text = fmt.Sprintf(core.Msg(c, "sandbox_status.left"), AgentDuration(left.Truncate(time.Second)))
wait := left % time.Second
if wait == 0 {
wait = time.Second
@@ -91,15 +92,15 @@ func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *
}
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(k.TextMuted)
agentCaption(c, text)
AgentCaption(c, text)
})
}
})
return card
}
// sandboxNum prints a resource amount with at most one decimal.
func sandboxNum(v float64) string {
// SandboxNum prints a resource amount with at most one decimal.
func SandboxNum(v float64) string {
if v == math.Trunc(v) {
return fmt.Sprintf("%.0f", v)
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"strings"
@@ -6,6 +6,8 @@ import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func sandboxSample(expires time.Time) SandboxInfo {
@@ -18,7 +20,7 @@ func sandboxSample(expires time.Time) SandboxInfo {
func TestSandboxStatusResourcesAndCountdown(t *testing.T) {
info := sandboxSample(time.Now().Add(90 * time.Second))
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
SandboxStatus(c, info, SandboxStatusOptions{Still: true})
}, 480, 300)
for _, s := range []string{"sandbox-7", "Running", "1.5 / 2 cores", "3.8 / 4 GB", "Near the limit"} {
@@ -54,7 +56,7 @@ func TestSandboxStatusRejectsBadResource(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
SandboxStatus(c, SandboxInfo{Name: "x", Resources: []SandboxResource{{Name: "CPU", Used: 3, Limit: 2}}}, SandboxStatusOptions{})
}, 200, 200)
}
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,9 +7,11 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ScreenshotFrame is one caller-supplied frame of the agent's screen.
@@ -34,7 +36,7 @@ type ScreenshotStreamOptions struct {
}
// ScreenshotStream shows screen frames live, paused or stepped.
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult {
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) core.ChoiceResult {
if st == nil {
panic("mujica: ScreenshotStream needs a state")
}
@@ -50,8 +52,8 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
if h == 0 {
h = 280
}
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
n := len(frames)
if st.Index < 0 {
panic(fmt.Sprintf("mujica: ScreenshotStream Index %d is negative", st.Index))
@@ -60,24 +62,24 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
st.Index = max(0, n-1)
}
var r ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "screenshot_stream.label"))
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "screenshot_stream.label"))
locked := r.Element.IsDisabled()
step := func(d int) {
if n == 0 || locked {
return
}
st.Index = max(0, min(n-1, st.Index+d))
st.Paused, r.changed = true, true
st.Paused, r = true, core.NewChoiceResult(r.Element, true)
}
r.Element.Children(func() {
view := ui.Box(c).FillWidth().Height(h).Center().Clip().Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Focusable().
Label(msg(c, "screenshot_stream.label"))
Label(core.Msg(c, "screenshot_stream.label"))
internal.Ring(view, k.Focus, theme.ControlRadius)
view.Children(func() {
if n == 0 {
agentCaption(c, msg(c, "screenshot_stream.waiting"))
AgentCaption(c, core.Msg(c, "screenshot_stream.waiting"))
return
}
ui.Image(c, frames[st.Index].Image).Fill().Fit(ui.Contain).Label(frames[st.Index].Caption)
@@ -89,36 +91,36 @@ func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []Screens
case view.Shortcut(0, ui.KeyRight):
step(1)
case view.Shortcut(0, ui.KeySpace):
st.Paused, r.changed = !st.Paused, true
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
}
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
step(-1)
}
pause := msg(c, "screenshot_stream.pause")
pause := core.Msg(c, "screenshot_stream.pause")
if st.Paused {
pause = msg(c, "screenshot_stream.live")
pause = core.Msg(c, "screenshot_stream.live")
}
if Button(c, pause, ButtonOptions{Variant: Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
st.Paused, r.changed = !st.Paused, true
if input.Button(c, pause, input.ButtonOptions{Variant: input.Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
}
if iconAction(c, icons.Must("chevron-right"), msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
step(1)
}
counter := fmt.Sprintf(msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
agentCaption(c, counter).Role(ui.RoleStatus)
counter := fmt.Sprintf(core.Msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
AgentCaption(c, counter).Role(ui.RoleStatus)
if n > 0 {
f := frames[st.Index]
ui.Text(c, f.Caption).SingleLine().Grow(1).Shrink(1)
agentCaption(c, agentDuration(f.At))
AgentCaption(c, AgentDuration(f.At))
}
if !st.Paused && n > 0 {
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: msg(c, "screenshot_stream.following"), Still: true})
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: core.Msg(c, "screenshot_stream.following"), Still: true})
}
})
})
if r.changed {
if r.Changed() {
slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused)
}
return r
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -8,6 +8,8 @@ import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// screenshotFrames makes n solid frames of shifting colors.
@@ -26,7 +28,7 @@ func screenshotFrames(n int) []ScreenshotFrame {
func screenshotTester(st *ScreenshotStreamState, frames *[]ScreenshotFrame, opts ScreenshotStreamOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if ScreenshotStream(c, st, *frames, opts).Changed() {
*changes++
}
+13 -12
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,9 +6,10 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SubAgent is one agent in a hierarchy.
@@ -27,7 +28,7 @@ type SubAgentTreeOptions struct {
}
// SubAgentTree shows nested agents and their states.
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult {
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) core.ChoiceResult {
seen := map[string]bool{}
var check func([]SubAgent)
check = func(list []SubAgent) {
@@ -40,7 +41,7 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
}
}
check(roots)
var r ChoiceResult
var r core.ChoiceResult
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
open := ui.Local(r.Element, "mujica.subagents.open", func() map[string]bool { return map[string]bool{} })
r.Element.Children(func() {
@@ -49,7 +50,7 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
for _, a := range list {
if subAgentRow(c, a, depth, (*open)[a.ID], opts.Still) {
(*open)[a.ID] = !(*open)[a.ID]
r.changed = true
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: sub_agent_tree toggle", "id", a.ID, "open", (*open)[a.ID])
}
if (*open)[a.ID] {
@@ -69,8 +70,8 @@ func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) Cho
// subAgentRow builds one row, reporting an expand or collapse.
func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
k := Tokens(c)
d := Density(c)
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
parent := len(a.Children) > 0
row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).
@@ -91,10 +92,10 @@ func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
}
agentMark(c, a.State, 16, still)
ui.Text(c, a.Name).FontWeight(600).SingleLine().Shrink(0)
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
agentCaption(c, agentWord(c, a.State))
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
AgentCaption(c, agentWord(c, a.State))
if parent {
agentCaption(c, fmt.Sprintf(msg(c, "sub_agent_tree.children"), len(a.Children)))
AgentCaption(c, fmt.Sprintf(core.Msg(c, "sub_agent_tree.children"), len(a.Children)))
}
})
if !parent || row.IsDisabled() {
@@ -1,9 +1,11 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var subAgentSample = []SubAgent{
@@ -18,7 +20,7 @@ var subAgentSample = []SubAgent{
func subAgentTester(opts SubAgentTreeOptions, changes *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if SubAgentTree(c, subAgentSample, opts).Changed() {
*changes++
}
@@ -66,7 +68,7 @@ func TestSubAgentTreeRejectsDuplicateIDs(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
SubAgentTree(c, []SubAgent{{ID: "a", Name: "x", Children: []SubAgent{{ID: "a", Name: "y"}}}}, SubAgentTreeOptions{})
}, 200, 200)
}
@@ -1,11 +1,14 @@
package mujica
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolApprovalOptions configure a ToolApprovalDialog. Tool is required.
@@ -31,26 +34,26 @@ func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) Too
}
var r ToolApprovalResult
was := *open
u := Density(c).Unit()
r.Element = Dialog(c, open, DialogOptions{
Title: msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true,
u := core.Density(c).Unit()
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
Title: core.Msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true,
Actions: func() {
// Deny first: a reflexive Enter never runs the tool.
if Button(c, msg(c, "tool_approval_dialog.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
if input.Button(c, core.Msg(c, "tool_approval_dialog.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() {
r.Denied = true
}
if Button(c, msg(c, "tool_approval_dialog.allow"), ButtonOptions{}).Clicked() {
if input.Button(c, core.Msg(c, "tool_approval_dialog.allow"), input.ButtonOptions{}).Clicked() {
r.Allowed = true
}
},
}, func() {
ui.Column(c).Gap(u * 2).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
agentCaption(c, msg(c, "tool_approval_dialog.tool"))
AgentCaption(c, core.Msg(c, "tool_approval_dialog.tool"))
ui.Text(c, opts.Tool).Font(theme.MonoFont).FontWeight(600)
})
if opts.Args != "" {
agentCode(c, "json", agentPrettyJSON(opts.Args), 260)
AgentCode(c, "json", agentPrettyJSON(opts.Args), 260)
}
})
})
@@ -1,9 +1,12 @@
package mujica
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
type approvalApp struct {
@@ -12,8 +15,8 @@ type approvalApp struct {
}
func (a *approvalApp) view(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Run", ButtonOptions{}).Clicked() {
core.Use(c, core.Settings{})
if input.Button(c, "Run", input.ButtonOptions{}).Clicked() {
a.open = true
}
r := ToolApprovalDialog(c, &a.open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"*.tmp"}`, Reason: "Clean the build folder."})
@@ -83,7 +86,7 @@ func TestToolApprovalDialogNeedsTool(t *testing.T) {
}()
open := true
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolApprovalDialog(c, &open, ToolApprovalOptions{})
}, 200, 200)
}
+20 -18
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"log/slog"
@@ -6,7 +6,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolCall is one tool invocation; the caller owns it.
@@ -33,14 +35,14 @@ type ToolCallCardOptions struct {
type toolCallCardState struct{ args, result bool }
// ToolCallCard shows a tool call with foldable arguments and result.
func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult {
func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) core.ChoiceResult {
if call.Name == "" {
panic("mujica: ToolCallCard needs a Name")
}
k := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = agentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State))
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State))
st := ui.Local(r.Element, "mujica.toolcall", func() toolCallCardState {
return toolCallCardState{opts.OpenArgs, opts.OpenResult}
})
@@ -48,31 +50,31 @@ func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) Choice
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
agentMark(c, call.State, 16, opts.Still)
ui.Text(c, call.Name).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
agentCaption(c, agentWord(c, call.State))
AgentCaption(c, agentWord(c, call.State))
if call.State == AgentDone || call.State == AgentFailed {
agentCaption(c, agentDuration(call.Duration))
AgentCaption(c, AgentDuration(call.Duration))
}
})
if call.Args != "" {
r.changed = Collapsible(c, &st.args, CollapsibleOptions{Title: msg(c, "tool_call_card.args")}, func() {
agentCode(c, "json", agentPrettyJSON(call.Args), 240)
}).Changed() || r.changed
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.args, data.CollapsibleOptions{Title: core.Msg(c, "tool_call_card.args")}, func() {
AgentCode(c, "json", agentPrettyJSON(call.Args), 240)
}).Changed() || r.Changed())
}
body, title := call.Result, msg(c, "tool_call_card.result")
body, title := call.Result, core.Msg(c, "tool_call_card.result")
if call.Error != "" {
body, title = call.Error, msg(c, "tool_call_card.error")
body, title = call.Error, core.Msg(c, "tool_call_card.error")
}
if body != "" {
r.changed = Collapsible(c, &st.result, CollapsibleOptions{Title: title}, func() {
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.result, data.CollapsibleOptions{Title: title}, func() {
if call.Error != "" {
ui.Text(c, call.Error).TextColor(k.Danger).Selectable()
return
}
agentCode(c, "json", agentPrettyJSON(body), 320)
}).Changed() || r.changed
AgentCode(c, "json", agentPrettyJSON(body), 320)
}).Changed() || r.Changed())
}
})
if r.changed {
if r.Changed() {
slog.Debug("mujica: tool_call_card toggle", "tool", call.Name, "args", st.args, "result", st.result)
}
return r
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -7,6 +7,8 @@ import (
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var toolCallSample = ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`,
@@ -16,7 +18,7 @@ func TestToolCallCardShowsAndFolds(t *testing.T) {
call := toolCallSample
changes := 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if ToolCallCard(c, call, ToolCallCardOptions{}).Changed() {
changes++
}
@@ -43,7 +45,7 @@ func TestToolCallCardShowsAndFolds(t *testing.T) {
func TestToolCallCardErrorAndPlainText(t *testing.T) {
call := ToolCall{Name: "shell", Args: "not json at all", Error: "permission denied", State: AgentFailed, Duration: 3 * time.Second}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{OpenArgs: true, OpenResult: true})
}, 500, 400)
for _, s := range []string{"Failed", "3.0 s", "Error", "permission denied", "not json at all"} {
@@ -56,7 +58,7 @@ func TestToolCallCardErrorAndPlainText(t *testing.T) {
func TestToolCallCardRunningHidesDuration(t *testing.T) {
call := ToolCall{Name: "search", State: AgentRunning, Duration: time.Second}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{Still: true})
}, 400, 200)
if !tt.HasText("Running") || tt.HasText("1.0 s") {
@@ -71,7 +73,7 @@ func TestToolCallCardLargeResultIsVirtualized(t *testing.T) {
}
call := ToolCall{Name: "dump", Result: b.String(), State: AgentDone}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{OpenResult: true})
}, 500, 500)
if n := len(tt.Texts()); n > 200 {
@@ -89,7 +91,7 @@ func TestToolCallCardNeedsName(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallCard(c, ToolCall{}, ToolCallCardOptions{})
}, 200, 200)
}
+14 -12
View File
@@ -1,4 +1,4 @@
package mujica
package agent
import (
"fmt"
@@ -6,7 +6,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolCallGroupOptions configure a ToolCallGroup.
@@ -17,7 +19,7 @@ type ToolCallGroupOptions struct {
}
// ToolCallGroup folds tool calls under a count, time, failures summary.
func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult {
func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) core.ChoiceResult {
if open == nil {
panic("mujica: ToolCallGroup needs open")
}
@@ -33,11 +35,11 @@ func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGro
failed += agentB2I(call.State == AgentFailed)
running = running || call.State == AgentRunning
}
k := Tokens(c)
u := Density(c).Unit()
k := core.Tokens(c)
u := core.Density(c).Unit()
title := opts.Title
if title == "" {
title = fmt.Sprintf(msg(c, "tool_call_group.count"), len(calls))
title = fmt.Sprintf(core.Msg(c, "tool_call_group.count"), len(calls))
}
state := AgentDone
switch {
@@ -46,18 +48,18 @@ func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGro
case failed > 0:
state = AgentFailed
}
summary := title + " · " + agentDuration(total)
summary := title + " · " + AgentDuration(total)
if failed > 0 {
summary += " · " + fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)
summary += " · " + fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)
}
return Collapsible(c, open, CollapsibleOptions{Title: summary, Trigger: func() {
return data.Collapsible(c, open, data.CollapsibleOptions{Title: summary, Trigger: func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
agentMark(c, state, 16, opts.Still)
ui.Text(c, title).FontWeight(600).SingleLine()
agentCaption(c, agentDuration(total))
AgentCaption(c, AgentDuration(total))
if failed > 0 {
ui.Text(c, fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger).
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
ui.Text(c, fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger).
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
}
})
}}, func() {
@@ -1,10 +1,12 @@
package mujica
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var toolCallGroupSample = []ToolCall{
@@ -16,7 +18,7 @@ var toolCallGroupSample = []ToolCall{
func TestToolCallGroupSummaryAndFold(t *testing.T) {
open, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
if ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}).Changed() {
changes++
}
@@ -40,7 +42,7 @@ func TestToolCallGroupRunningAndEmpty(t *testing.T) {
open := false
calls := []ToolCall{{ID: "x", Name: "fetch", State: AgentRunning}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallGroup(c, &open, calls, ToolCallGroupOptions{Title: "Research", Still: true})
ToolCallGroup(c, &open, nil, ToolCallGroupOptions{})
}, 500, 300)
@@ -57,7 +59,7 @@ func TestToolCallGroupRejectsDuplicateIDs(t *testing.T) {
}()
open := false
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
ToolCallGroup(c, &open, []ToolCall{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, ToolCallGroupOptions{})
}, 200, 200)
}
+5 -5
View File
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"math"
@@ -24,15 +24,15 @@ type AreaChartOptions struct {
// AreaChart fills series to the baseline, or stacked.
func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult {
n := chartCheckSeries(series)
ch := newChart(c, opts.Frame)
ax := chartMemo(ch, "x", [3]string{chartID(opts.X), chartID(opts.Times), chartID(series)}, func() chartX { return newChartX(n, opts.X, opts.Times) })
ch := NewChart(c, opts.Frame)
ax := ChartMemo(ch, "x", [3]string{ChartID(opts.X), ChartID(opts.Times), ChartID(series)}, func() ChartX { return NewChartX(n, opts.X, opts.Times) })
deps := struct {
id string
stacked bool
base float64
}{chartSeriesID(ch, series), opts.Stacked, opts.Baseline}
bands := chartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.hidden, opts.Stacked, opts.Baseline) })
lo, hi, ok := chartYRange(ch, series, false, bands[0])
bands := ChartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.Hidden, opts.Stacked, opts.Baseline) })
lo, hi, ok := ChartYRange(ch, series, false, bands[0])
if !ok {
n = 0
}
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"math"
+21 -20
View File
@@ -1,12 +1,13 @@
package mujica
package chart
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BarChartOptions configure a BarChart.
@@ -28,15 +29,15 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
if n := chartCheckSeries(series); n != len(categories) && len(series) > 0 {
panic("mujica: BarChart series length differs from categories")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
ch := NewChart(c, opts.Frame)
k := core.Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
size := core.FontSize(c, theme.CaptionSize)
keys := make([]ChartLegendItem, len(series))
var vis []int
for i, s := range series {
keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
if !ch.hidden(s.Name) {
if !ch.Hidden(s.Name) {
vis = append(vis, i)
}
}
@@ -46,7 +47,7 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
from []float64
lo, hi float64
}
lay := chartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
lay := ChartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
var l layout
for ci := range categories {
pos, neg := 0.0, 0.0
@@ -81,11 +82,11 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
}
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
if opts.Horizontal {
plot := chartInset(r, 96, 26)
plot := ChartInset(r, 96, 26)
return plot, internal.Band{N: len(categories), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
plot := chartInset(r, 52, 26)
plot := ChartInset(r, 52, 26)
return plot, internal.Band{N: len(categories), R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
@@ -111,10 +112,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
return ui.Rect{X: at, Y: a, W: w, H: max(b-a, 1)}
}
value := func(i int) float64 { m := lay.marks[i]; return series[m.series].Values[m.cat] }
spec := ChartSpec{Marks: len(lay.marks), Legend: keys, Hidden: ch.toggles(len(series) > 1)}
spec := ChartSpec{Marks: len(lay.marks), Legend: keys, Hidden: ch.Toggles(len(series) > 1)}
spec.Readout = func(i int) string {
m := lay.marks[i]
return categories[m.cat] + " · " + series[m.series].Name + " " + chartValue(c, value(i), opts.Format)
return categories[m.cat] + " · " + series[m.series].Name + " " + ChartValue(c, value(i), opts.Format)
}
spec.Pick = func(r ui.Rect, x, y float32) int {
_, band, vs := geom(r)
@@ -139,10 +140,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
for _, t := range ticks {
x := internal.Snap(p, vs.Map(t))
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.Border)
chartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
ChartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
}
for i, y := range pos {
chartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
ChartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
}
p.Line(internal.Snap(p, zero), plot.Y, internal.Snap(p, zero), plot.Y+plot.H, 1, k.ControlBorder)
} else {
@@ -162,11 +163,11 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
internal.Marker(p, m.series, b.X+b.W/2, b.Y+b.H/2, min(b.W, b.H, 14)*0.6, k.Surface)
}
if opts.Labels && !opts.Stacked {
barLabel(p, chartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
barLabel(p, ChartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
}
}
}
res, _ := ch.build(spec)
res, _ := ch.Build(spec)
return res
}
@@ -174,12 +175,12 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar
func barLabel(p *ui.Painter, s string, b ui.Rect, neg, horizontal bool, size float32, col ui.Color) {
switch {
case horizontal && neg:
chartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
ChartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
case horizontal:
chartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
ChartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
case neg:
chartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
ChartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
default:
chartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
ChartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
}
}
@@ -1,10 +1,12 @@
package mujica
package chart
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var barCats = []string{"Nave", "Choir", "Crypt"}
@@ -71,7 +73,7 @@ func TestBarChartBadAndEmpty(t *testing.T) {
t.Fatalf("empty: %q", tt.Texts())
}
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
BarChart(c, barCats[:2], barSeries(), BarChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
+14 -13
View File
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"fmt"
@@ -7,8 +7,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BoxGroup is one named sample of a BoxPlot.
@@ -65,18 +66,18 @@ func boxSummary(values []float64) boxStats {
// BoxPlot compares groups by quartile box, whiskers and outlier points.
func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult {
ch := newChart(c, opts.Frame)
k := Tokens(c)
ch := NewChart(c, opts.Frame)
k := core.Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
size := core.FontSize(c, theme.CaptionSize)
ids := ""
for _, g := range groups {
if g.Name == "" {
panic("mujica: BoxPlot groups need names")
}
ids += chartID(g.Values) + ";"
ids += ChartID(g.Values) + ";"
}
stats := chartMemo(ch, "stats", ids, func() []boxStats {
stats := ChartMemo(ch, "stats", ids, func() []boxStats {
out := make([]boxStats, len(groups))
for i, g := range groups {
out[i] = boxSummary(g.Values)
@@ -96,9 +97,9 @@ func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult
} else {
groups = nil
}
val := func(v float64) string { return chartValue(c, v, opts.Format) }
val := func(v float64) string { return ChartValue(c, v, opts.Format) }
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
plot := chartInset(r, 52, 26)
plot := ChartInset(r, 52, 26)
return plot, internal.Band{N: len(groups), R0: plot.X, R1: plot.X + plot.W, Pad: 0.45},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
@@ -106,9 +107,9 @@ func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult
spec.Readout = func(i int) string {
s := stats[i]
if s.n == 0 {
return groups[i].Name + " · " + msg(c, "chart.missing")
return groups[i].Name + " · " + core.Msg(c, "chart.missing")
}
return fmt.Sprintf(msg(c, "box_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), val(s.lo), val(s.hi), len(s.outliers))
return fmt.Sprintf(core.Msg(c, "box_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), val(s.lo), val(s.hi), len(s.outliers))
}
spec.Pick = func(r ui.Rect, x, y float32) int {
plot, band, _ := geom(r)
@@ -155,6 +156,6 @@ func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult
}
}
}
res, _ := ch.build(spec)
res, _ := ch.Build(spec)
return res
}
+1 -1
View File
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"fmt"
+14 -13
View File
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"fmt"
@@ -7,8 +7,9 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// BulletChartOptions configure a BulletChart; Frame.Height 0 means 72 DIPs.
@@ -32,9 +33,9 @@ func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartRes
if opts.Frame.Height == 0 {
opts.Frame.Height = 72
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
ch := NewChart(c, opts.Frame)
k := core.Tokens(c)
size := core.FontSize(c, theme.CaptionSize)
top := opts.Max
if top == 0 {
top = value
@@ -47,17 +48,17 @@ func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartRes
_, top, _ = internal.Nice(0, max(top, 1e-9), 4)
}
_, _, step := internal.Nice(0, top, 4)
val := func(v float64) string { return chartValue(c, v, opts.Format) }
marks := []string{msg(c, "bullet_chart.actual") + " " + val(value)}
val := func(v float64) string { return ChartValue(c, v, opts.Format) }
marks := []string{core.Msg(c, "bullet_chart.actual") + " " + val(value)}
if opts.Target != nil {
marks = append(marks, msg(c, "bullet_chart.target")+" "+val(*opts.Target))
marks = append(marks, core.Msg(c, "bullet_chart.target")+" "+val(*opts.Target))
}
for i, b := range opts.Bands {
lo := 0.0
if i > 0 {
lo = opts.Bands[i-1]
}
marks = append(marks, fmt.Sprintf(msg(c, "bullet_chart.band"), i+1, val(lo), val(b)))
marks = append(marks, fmt.Sprintf(core.Msg(c, "bullet_chart.band"), i+1, val(lo), val(b)))
}
// geom returns the plot and its value scale.
geom := func(r ui.Rect) (ui.Rect, internal.Linear) {
@@ -130,13 +131,13 @@ func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartRes
}
for _, tk := range internal.Ticks(0, top, step) {
if opts.Vertical {
chartText(p, internal.FormatTick(tk, step), plot.X+plot.W+6, s.Map(tk), size, k.TextMuted, 0, 0.5)
ChartText(p, internal.FormatTick(tk, step), plot.X+plot.W+6, s.Map(tk), size, k.TextMuted, 0, 0.5)
} else {
chartText(p, internal.FormatTick(tk, step), s.Map(tk), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
ChartText(p, internal.FormatTick(tk, step), s.Map(tk), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
}
}
}
res, _ := ch.build(spec)
res, _ := ch.Build(spec)
return res
}
@@ -1,9 +1,11 @@
package mujica
package chart
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestBulletChartReadout(t *testing.T) {
@@ -49,7 +51,7 @@ func TestBulletChartVerticalAndBad(t *testing.T) {
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
BulletChart(c, 1, BulletChartOptions{Frame: ChartFrame{Label: "x"}, Bands: []float64{5, 2}})
}, 300, 300)
}
@@ -1,4 +1,4 @@
package mujica
package chart
import (
"log/slog"
@@ -7,31 +7,33 @@ import (
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CalendarHeatmapOptions configure a CalendarHeatmap.
type CalendarHeatmapOptions struct {
Frame ChartFrame
// WeekStart picks each column's first weekday; zero follows the locale.
WeekStart WeekStart
WeekStart datetime.WeekStart
Format func(v float64) string
}
// CalendarHeatmap colors each day of *year, a column per week.
func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts CalendarHeatmapOptions) ChartResult {
func CalendarHeatmap(c *ui.Context, year *int, values map[core.Date]float64, opts CalendarHeatmapOptions) ChartResult {
if *year < 1 || *year > 9999 {
panic("mujica: CalendarHeatmap year out of range")
}
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
first := Date{Year: *year, Month: time.January, Day: 1}
days := first.AddMonths(12).civil().Sub(first.civil()).Hours() / 24
k := core.Tokens(c)
u := core.Density(c).Unit()
size := core.FontSize(c, theme.CaptionSize)
first := core.Date{Year: *year, Month: time.January, Day: 1}
days := first.AddMonths(12).Civil().Sub(first.Civil()).Hours() / 24
n := int(days)
lead := (int(first.Weekday()) - int(calendarFirstWeekday(c, opts.WeekStart)) + 7) % 7
lead := (int(first.Weekday()) - int(datetime.CalendarFirstWeekday(c, opts.WeekStart)) + 7) % 7
weeks := (lead + n + 6) / 7
top := 0.0
for i := range n {
@@ -51,9 +53,9 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
prev := iconAction(c, icons.Must("chevron-left"), msg(c, "calendar_heatmap.prev")).Disabled(*year <= 1)
ui.Text(c, strconv.Itoa(*year)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).Width(64).TextAlign(ui.Center)
next := iconAction(c, icons.Must("chevron-right"), msg(c, "calendar_heatmap.next")).Disabled(*year >= 9999)
prev := core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "calendar_heatmap.prev")).Disabled(*year <= 1)
ui.Text(c, strconv.Itoa(*year)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).Width(64).TextAlign(ui.Center)
next := core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "calendar_heatmap.next")).Disabled(*year >= 9999)
if prev.Clicked() && !prev.IsDisabled() {
*year--
res.value = true
@@ -63,7 +65,7 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal
res.value = true
}
})
ch := newChart(c, opts.Frame)
ch := NewChart(c, opts.Frame)
geom := func(r ui.Rect) (x, y, step float32) {
step = min((r.W-36)/float32(weeks), (r.H-48)/7)
return r.X + 32, r.Y + 18, step
@@ -79,9 +81,9 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal
v, ok := values[d]
s := d.String() + " · "
if !ok {
return s + msg(c, "chart.missing")
return s + core.Msg(c, "chart.missing")
}
return s + chartValue(c, v, opts.Format)
return s + ChartValue(c, v, opts.Format)
}
spec.Move = func(i int, key ui.Key) int {
step := map[ui.Key]int{ui.KeyLeft: -7, ui.KeyRight: 7, ui.KeyUp: -1, ui.KeyDown: 1}[key]
@@ -106,18 +108,18 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal
p.Fill(b, internal.Ramp(k, float64(lv)/4), 2)
}
if d := first.AddDays(i); d.Day == 1 {
chartText(p, d.Format("Jan"), b.X, y0-4, size, k.TextMuted, 0, 1)
ChartText(p, d.Format("Jan"), b.X, y0-4, size, k.TextMuted, 0, 1)
}
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
}
}
for row := 0; row < 7; row += 2 {
wd := time.Weekday((int(calendarFirstWeekday(c, opts.WeekStart)) + row) % 7)
chartText(p, wd.String()[:3], x0-4, y0+float32(row)*step+step/2, size, k.TextMuted, 1, 0.5)
wd := time.Weekday((int(datetime.CalendarFirstWeekday(c, opts.WeekStart)) + row) % 7)
ChartText(p, wd.String()[:3], x0-4, y0+float32(row)*step+step/2, size, k.TextMuted, 1, 0.5)
}
ly := y0 + 7*step + 8
w, _ := chartText(p, msg(c, "calendar_heatmap.less"), x0, ly, size, k.TextMuted, 0, 0)
w, _ := ChartText(p, core.Msg(c, "calendar_heatmap.less"), x0, ly, size, k.TextMuted, 0, 0)
for lv := range 5 {
sw := ui.Rect{X: x0 + w + 6 + float32(lv)*(step+2), Y: ly + 2, W: step - 2, H: step - 2}
if lv == 0 {
@@ -126,9 +128,9 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal
p.Fill(sw, internal.Ramp(k, float64(lv)/4), 2)
}
}
chartText(p, msg(c, "calendar_heatmap.more"), x0+w+12+5*(step+2), ly, size, k.TextMuted, 0, 0)
ChartText(p, core.Msg(c, "calendar_heatmap.more"), x0+w+12+5*(step+2), ly, size, k.TextMuted, 0, 0)
}
res2, _ := ch.build(spec)
res2, _ := ch.Build(spec)
res.Point, res.changed, res.retry = res2.Point, res2.changed, res2.retry
})
if res.value {
@@ -1,15 +1,18 @@
package mujica
package chart
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
)
func calendarValues() map[Date]float64 {
m := map[Date]float64{}
d := Date{Year: 2026, Month: time.January, Day: 1}
func calendarValues() map[core.Date]float64 {
m := map[core.Date]float64{}
d := core.Date{Year: 2026, Month: time.January, Day: 1}
for i := range 365 {
if i%3 == 0 {
m[d.AddDays(i)] = float64(i % 11)
@@ -21,7 +24,7 @@ func calendarValues() map[Date]float64 {
func TestCalendarHeatmapKeysAndYear(t *testing.T) {
year := 2026
values := 0
start := WeekStartMonday
start := datetime.WeekStartMonday
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: start})
if r.ValueChanged() {
@@ -55,9 +58,9 @@ func TestCalendarHeatmapKeysAndYear(t *testing.T) {
func TestCalendarHeatmapWeekStartHover(t *testing.T) {
year := 2026 // 1 January 2026 is a Thursday
for _, tc := range []struct {
start WeekStart
start datetime.WeekStart
row int
}{{WeekStartSunday, 4}, {WeekStartMonday, 3}} {
}{{datetime.WeekStartSunday, 4}, {datetime.WeekStartMonday, 3}} {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: tc.start})
}}
@@ -80,7 +83,7 @@ func TestCalendarHeatmapBadYear(t *testing.T) {
}()
year := 0
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
core.Use(c, core.Settings{})
CalendarHeatmap(c, &year, nil, CalendarHeatmapOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
@@ -89,7 +92,7 @@ func TestCalendarHeatmapSnapshot(t *testing.T) {
snapshot(t, "calendar_heatmap", 600, 260, func(c *ui.Context) {
year := 2026
ui.Column(c).Padding(16).Children(func() {
CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers", Height: 150}, WeekStart: WeekStartMonday})
CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers", Height: 150}, WeekStart: datetime.WeekStartMonday})
})
})
}
Loaded 100 of 1822 files, more files were not shown because too many files have changed in this diff. Show more