Merge Settings & Account

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:46:45 -04:00
commit 0e51784330
108 files changed
+6501 -2

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AccentColorPickerOptions configure an AccentColorPicker.
type AccentColorPickerOptions struct {
// Candidates defaults to the built-in court palette.
Candidates []ui.Color
Disabled bool
}
// accentPickerColors offers readable accents and marked low-contrast examples.
var accentPickerColors = []ui.Color{
ui.Hex("#7D2034"), ui.Hex("#355E80"), ui.Hex("#326247"), ui.Hex("#4B3A5A"),
ui.Hex("#805714"), ui.Hex("#211C1D"), ui.Hex("#AD9164"), ui.Hex("#D9A5B3"),
}
// accentPickerReadable reports whether accent carries OnAccent text at 4.5:1.
func accentPickerReadable(k theme.Tokens, accent ui.Color) bool {
return theme.Contrast(k.OnAccent, accent) >= 4.5
}
// AccentColorPicker selects an accent and marks insufficient contrast.
func AccentColorPicker(c *ui.Context, color *ui.Color, opts AccentColorPickerOptions) ChoiceResult {
cands := opts.Candidates
if cands == nil {
cands = accentPickerColors
}
if len(cands) == 0 {
panic("mujica: AccentColorPicker needs candidates")
}
k := Tokens(c)
d := Density(c)
var r 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")).
FocusGroup(ui.Horizontal).Disabled(opts.Disabled).Children(func() {
selected := color
if r.Element.IsDisabled() || opts.Disabled {
copy := *color
selected = &copy
}
for _, col := range cands {
ok := accentPickerReadable(k, col)
low = low || !ok
hex := strings.ToUpper(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"))
}
if sw.Changed() && !sw.IsDisabled() {
r.changed = true
slog.Debug("mujica: accent_color_picker chose", "color", hex)
}
internal.Ring(sw, k.Focus, theme.CardRadius)
on := *color == col
sw.Draw(func(p *ui.Painter, rect ui.Rect) {
p.Fill(rect, col, theme.CardRadius)
p.Stroke(rect, k.Border, theme.CardRadius, 1)
if on {
p.Stroke(ui.Rect{X: rect.X - 3, Y: rect.Y - 3, W: rect.W + 6, H: rect.H + 6}, k.Text, theme.CardRadius+3, 1.5)
}
})
sw.Children(func() {
switch {
case on:
ui.Icon(c, icons.Must("check")).Size(16, 16).TextColor(accentPickerMark(col))
case !ok:
ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14).TextColor(accentPickerMark(col))
}
})
}
})
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))
})
}
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)
}
})
return r
}
// accentPickerMark is black or white, whichever reads on col.
func accentPickerMark(col ui.Color) ui.Color {
if theme.Contrast(col, ui.RGB(255, 255, 255)) >= theme.Contrast(col, ui.RGB(0, 0, 0)) {
return ui.RGB(255, 255, 255)
}
return ui.RGB(0, 0, 0)
}
+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
func TestAccentColorPickerMarksLowContrast(t *testing.T) {
col, n := ui.Hex("#7D2034"), 0
cands := []ui.Color{ui.Hex("#7D2034"), ui.Hex("#F0E68C")}
tt := accountTester(400, 200, func(c *ui.Context) {
if AccentColorPicker(c, &col, AccentColorPickerOptions{Candidates: cands}).Changed() {
n++
}
})
k := theme.Light()
if !accentPickerReadable(k, cands[0]) || accentPickerReadable(k, cands[1]) {
t.Fatal("contrast check wrong")
}
if !tt.HasText("Marked colors are too faint for text on them.") {
t.Fatalf("no low-contrast note: %q", tt.Texts())
}
if err := tt.Click("#F0E68C"); err != nil {
t.Fatal(err)
}
if col != cands[1] || n != 1 || !tt.HasText("#F0E68C has low contrast; text on it may be hard to read.") {
t.Fatalf("pick: %v %d %q", col, n, tt.Texts())
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeySpace)
if col != cands[0] || n != 2 {
t.Fatalf("keys: %v", col)
}
}
// Built-in accents meet their promised light-theme contrast.
func TestAccentColorPickerDefaults(t *testing.T) {
low := 0
for _, col := range accentPickerColors {
if !accentPickerReadable(theme.Light(), col) {
low++
}
}
if low != 2 {
t.Fatalf("%d defaults fail; the palette shows two faint ones on purpose", low)
}
}
func TestAccentColorPickerSnapshot(t *testing.T) {
snapshot(t, "accent_color_picker", 420, 120, func(c *ui.Context) {
col := ui.Local(c.Root(), "col", func() ui.Color { return ui.Hex("#355E80") })
ui.Column(c).Padding(16).Children(func() { AccentColorPicker(c, col, AccentColorPickerOptions{}) })
})
}
func TestAccentColorPickerDisabledAndEmptyCandidates(t *testing.T) {
col := ui.Hex("#7D2034")
tt := accountTester(600, 300, func(c *ui.Context) {
ui.Column(c).Disabled(true).Children(func() { AccentColorPicker(c, &col, AccentColorPickerOptions{}) })
})
if err := tt.Click("#355E80"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
if col != ui.Hex("#7D2034") {
t.Fatal("disabled swatch changes")
}
accountPanics(t, func(c *ui.Context) { AccentColorPicker(c, &col, AccentColorPickerOptions{Candidates: []ui.Color{}}) })
}
+150
View File
@@ -0,0 +1,150 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Account is a signed-in account a user can switch to.
type Account struct {
ID, Name, Email string
Avatar *ui.Bitmap
}
// AccountSwitcherOptions configure an AccountSwitcher.
type AccountSwitcherOptions struct {
Disabled bool
}
// AccountSwitcherResult reports account changes and addition requests.
type AccountSwitcherResult struct {
Element *ui.Element
changed bool
add bool
}
// Changed reports a switch to another account.
func (r AccountSwitcherResult) Changed() bool { return r.changed }
// Add reports a request to sign in another account.
func (r AccountSwitcherResult) Add() bool { return r.add }
// AccountSwitcher offers account switching and an addition action.
func AccountSwitcher(c *ui.Context, current *string, accounts []Account, opts AccountSwitcherOptions) AccountSwitcherResult {
var cur *Account
for i := range accounts {
if accounts[i].ID == "" || accounts[i].Name == "" {
panic("mujica: Account needs an ID and a Name")
}
if accounts[i].ID == *current {
cur = &accounts[i]
}
}
if cur == nil {
panic(fmt.Sprintf("mujica: AccountSwitcher current %q is not an account", *current))
}
var r AccountSwitcherResult
b := accountTrigger(c, cur.Name, cur.Email, cur.Avatar, opts.Disabled)
r.Element = b
open := ui.Local(b, "open", func() bool { return false })
if b.IsDisabled() {
*open = false
} else if b.Clicked() || b.Shortcut(0, ui.KeyDown) {
*open = true
}
if len(accounts) > 8 {
accountSwitcherLarge(c, b, open, current, accounts, &r)
return r
}
popupMenuOpen(c, b, open, func(m *PopupMenu) {
m.Group(msg(c, "account_switcher.accounts"), func() {
for _, a := range accounts {
on := a.ID == *current
label := a.Name
if a.Email != "" {
label += " · " + a.Email
}
ui.Column(c).Key(a.ID).Shrink(0).Children(func() {
if m.Check(label, &on, PopupMenuItemOptions{}) && a.ID != *current && !b.IsDisabled() {
*current, r.changed = a.ID, true
slog.Debug("mujica: account_switcher switched")
}
})
}
})
m.Separator()
if m.Item(msg(c, "account_switcher.add"), PopupMenuItemOptions{}) && !b.IsDisabled() {
r.add = true
slog.Debug("mujica: account_switcher add")
}
})
return r
}
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() {
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)
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)
if row.Focused() && !b.IsDisabled() {
*cursor = i
}
if row.Clicked() && !row.IsDisabled() && !b.IsDisabled() {
r.changed, *current, *open = a.ID != *current, a.ID, false
}
}).Height(280).MinHeight(0).AutoFocus()
restore()
internal.Ring(list, Tokens(c).Focus, theme.ControlRadius)
if list.Changed() && !b.IsDisabled() {
list.Focus()
}
if list.Submitted() && !b.IsDisabled() && *cursor >= 0 && *cursor < len(accounts) {
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() {
r.add, *open = true, false
}
})
if r.changed || r.add {
slog.Debug("mujica: account_switcher action", "changed", r.changed, "add", r.add)
}
}
// 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)
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)
internal.Ring(b, k.Focus, theme.ControlRadius)
switch {
case b.Pressed():
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.Children(func() {
Avatar(c, name, 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.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
})
return b
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var switcherAccounts = []Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis", Email: "ob@mask"}}
func TestAccountSwitcherSwitchesAndAdds(t *testing.T) {
cur, n, adds := "a", 0, 0
disabled := false
tt := accountTester(500, 400, func(c *ui.Context) {
r := AccountSwitcher(c, &cur, switcherAccounts, AccountSwitcherOptions{Disabled: disabled})
if r.Changed() {
n++
}
if r.Add() {
adds++
}
})
if err := tt.Click("Sakiko Togawa"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Oblivionis · ob@mask"); err != nil {
t.Fatal(err)
}
if cur != "b" || n != 1 {
t.Fatalf("switch: %q %d", cur, n)
}
tt.Key(0, ui.KeyDown)
if err := tt.Click("Add account…"); err != nil {
t.Fatal(err)
}
if adds != 1 || cur != "b" {
t.Fatal("add not reported")
}
disabled = true
tt.Frame()
_ = tt.Click("Oblivionis")
if tt.HasText("Add account…") {
t.Fatal("disabled switcher opened")
}
defer func() {
if recover() == nil {
t.Fatal("unknown current accepted")
}
}()
bad := "z"
accountTester(100, 100, func(c *ui.Context) { AccountSwitcher(c, &bad, switcherAccounts, AccountSwitcherOptions{}) })
}
func TestAccountSwitcherSnapshot(t *testing.T) {
snapshot(t, "account_switcher", 300, 90, func(c *ui.Context) {
cur := ui.Local(c.Root(), "cur", func() string { return "a" })
ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() { AccountSwitcher(c, cur, switcherAccounts, AccountSwitcherOptions{}) })
})
}
func TestAccountSwitcherLargeDataBuildsVisibleRows(t *testing.T) {
items := make([]Account, 100000)
for i := range items {
items[i] = Account{ID: fmt.Sprint(i), Name: fmt.Sprintf("Account %d", i)}
}
cur := "0"
tt := accountTester(500, 500, func(c *ui.Context) { AccountSwitcher(c, &cur, items, AccountSwitcherOptions{}) })
if err := tt.Click("Account 0"); err != nil {
t.Fatal(err)
}
if n := len(tt.Texts()); n > 100 {
t.Fatalf("built %d text elements for a bounded viewport", n)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyEnter)
if cur != "99999" {
t.Fatalf("large-list keyboard choice %q", cur)
}
}
func TestAccountSwitcherTabChoosesFocusedLargeRow(t *testing.T) {
items := make([]Account, 9)
for i := range items {
items[i] = Account{ID: fmt.Sprint(i), Name: fmt.Sprintf("Account %d", i)}
}
cur := "0"
tt := accountTester(500, 500, func(c *ui.Context) { AccountSwitcher(c, &cur, items, AccountSwitcherOptions{}) })
if err := tt.Click("Account 0"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
if !tt.Focused("Account 1") {
t.Fatal("Tab did not reach the second account")
}
tt.Key(0, ui.KeyEnter)
if cur != "1" {
t.Fatal("Enter did not choose the focused account")
}
}
func TestAccountSwitcherFocusFollowsReorderedID(t *testing.T) {
items := []Account{{ID: "a", Name: "A"}, {ID: "b", Name: "B"}, {ID: "c", Name: "C"}}
cur := "a"
tt := accountTester(500, 500, func(c *ui.Context) { AccountSwitcher(c, &cur, items, AccountSwitcherOptions{}) })
if err := tt.Click("A"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyDown)
if !tt.Focused("B") {
t.Fatal("Down did not reach B")
}
items[1], items[2] = items[2], items[1]
tt.Frame()
tt.Key(0, ui.KeyEnter)
if cur != "b" {
t.Fatalf("reordered focus chose %q", cur)
}
}
func TestAccountSwitcherArrowsContinueFromTabFocus(t *testing.T) {
items := make([]Account, 9)
for i := range items {
items[i] = Account{ID: fmt.Sprint(i), Name: fmt.Sprintf("Account %d", i)}
}
cur := "0"
tt := accountTester(500, 500, func(c *ui.Context) { AccountSwitcher(c, &cur, items, AccountSwitcherOptions{}) })
if err := tt.Click("Account 0"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if cur != "2" {
t.Fatalf("arrow choice after Tab: %q", cur)
}
}
+204
View File
@@ -0,0 +1,204 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// APIKey contains public metadata and the visible prefix.
type APIKey struct {
ID, Name, Prefix string
Created, LastUsed time.Time
}
// ApiKeyManagerState holds creation, confirmation and one-time reveal state.
type ApiKeyManagerState struct {
creating, pending bool
name, secret string
err string
revoking string
keys []APIKey
index map[string]APIKey
}
// Reveal presents the generated secret until dismissal.
func (s *ApiKeyManagerState) Reveal(secret string) {
if !s.pending || secret == "" {
panic("mujica: ApiKeyManagerState.Reveal needs a create in progress and a secret")
}
s.pending, s.secret, s.err = false, secret, ""
slog.Debug("mujica: api_key_manager revealed")
}
// Fail displays a caller-provided creation error.
func (s *ApiKeyManagerState) Fail(err string) {
if !s.pending {
panic("mujica: ApiKeyManagerState.Fail needs a create in progress")
}
s.creating, s.pending, s.err = true, false, err
}
// ApiKeyManagerOptions configure an ApiKeyManager.
type ApiKeyManagerOptions struct {
Disabled bool
}
// ApiKeyManagerResult reports creation and revocation requests.
type ApiKeyManagerResult struct {
Element *ui.Element
create, revoke string
}
// Create returns the requested name; finish with Reveal or Fail.
func (r ApiKeyManagerResult) Create() (string, bool) { return r.create, r.create != "" }
// Revoked returns the confirmed key ID.
func (r ApiKeyManagerResult) Revoked() (string, bool) { return r.revoke, r.revoke != "" }
// 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)
var r ApiKeyManagerResult
r.Element = ui.Column(c).Gap(d.Unit() * 3).MinWidth(460).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
if disabled {
s.creating = s.creating && (s.pending || s.err != "")
s.revoking, s.secret = "", ""
}
if s.index == nil || !slices.Equal(s.keys, keys) {
s.keys, s.index = slices.Clone(keys), map[string]APIKey{}
for _, key := range keys {
if key.ID == "" || key.Name == "" || key.Prefix == "" {
panic("mujica: APIKey needs an ID, a Name and a Prefix")
}
if _, ok := s.index[key.ID]; ok {
panic("mujica: APIKey IDs must be unique")
}
s.index[key.ID] = key
}
}
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 {
s.creating, s.name, s.err = true, "", ""
}
})
if len(keys) == 0 {
EmptyState(c, EmptyStateOptions{Title: msg(c, "api_key_manager.empty")})
}
accountRows(c, len(keys), func(i int) any { return keys[i].ID }, func(i int) {
key := keys[i]
ui.Row(c).Key(key.ID).MinHeight(d.ControlHeight()+d.Unit()*4).Gap(d.Unit()*3).AlignItems(ui.Center).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
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))
})
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))
if b.Clicked() && !disabled {
s.revoking = key.ID
}
})
})
})
if name, ok := apiKeyCreateDialog(c, s, disabled); ok && !disabled {
r.create = name
slog.Debug("mujica: api_key_manager create")
}
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")})
if res.Confirmed && !disabled {
r.revoke = victim.ID
slog.Debug("mujica: api_key_manager revoke")
}
if !open {
s.revoking = ""
}
return r
}
// apiKeyMasked shows the prefix and hides the rest.
func apiKeyMasked(prefix string) string { return prefix + strings.Repeat("•", 12) }
// 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")
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)
}
// 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)
open := !disabled && (s.creating || s.pending || s.secret != "")
was := open
asked := false
title := msg(c, "api_key_manager.create_title")
if s.secret != "" {
title = msg(c, "api_key_manager.secret_title")
}
Dialog(c, &open, 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() {
open = false
}
default:
if Button(c, msg(c, "api_key_manager.cancel"), ButtonOptions{Variant: 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 {
asked = true
}
}
}}, func() {
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.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() {
c.WriteClipboard(s.secret)
c.Announce(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"),
Disabled: s.pending, Error: s.err})
res.Input.AutoFocus()
if res.Submitted() && strings.TrimSpace(s.name) != "" && !s.pending {
asked = true
}
})
if was && !open && !s.pending {
// Dismissing wipes the one-time secret.
s.creating, s.secret, s.name = false, "", ""
}
if asked {
s.pending, s.err = true, ""
return strings.TrimSpace(s.name), true
}
return "", false
}
+201
View File
@@ -0,0 +1,201 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var apiKeys = []APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c", Created: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)}}
func TestApiKeyManagerCreateRevealOnceAndRevoke(t *testing.T) {
logs := accountLogs(t)
const secret = "mk_live_9f2c_SECRET_DO_NOT_LOG"
var s ApiKeyManagerState
var created, revoked []string
tt := accountTester(700, 600, func(c *ui.Context) {
r := ApiKeyManager(c, &s, apiKeys, ApiKeyManagerOptions{})
if name, ok := r.Create(); ok {
created = append(created, name)
}
if id, ok := r.Revoked(); ok {
revoked = append(revoked, id)
}
})
if !tt.HasText("mk_live_9f2c••••••••••••") || !tt.HasText("Created 2026-09-01 · last used never") {
t.Fatalf("masked list: %q", tt.Texts())
}
_ = tt.Click("Create key")
tt.Type("CI")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEnter)
if len(created) != 1 || created[0] != "CI" {
t.Fatalf("created %v", created)
}
s.Reveal(secret)
tt.Frame()
if s.secret != secret || !tt.HasText("Copy key") {
t.Fatal("new key not shown")
}
if err := tt.Click("Copy key"); err != nil {
t.Fatal(err)
}
if tt.Clipboard() != secret {
t.Fatal("copy did not take the key")
}
_ = tt.Click("I've saved it")
if tt.HasText("Copy key") || s.secret != "" {
t.Fatal("key still shown or kept after dismissal")
}
if strings.Contains(logs.String(), secret) {
t.Fatal("the key reached the log")
}
_ = tt.Click("Revoke Deploy bot")
if !tt.Focused("Cancel") {
t.Fatal("revoke confirmation not focused on Cancel")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(revoked) != 1 || revoked[0] != "k1" {
t.Fatalf("revoked %v", revoked)
}
}
func TestApiKeyManagerFailAndDisabled(t *testing.T) {
var s ApiKeyManagerState
disabled, made := false, 0
tt := accountTester(700, 600, func(c *ui.Context) {
if _, ok := ApiKeyManager(c, &s, nil, ApiKeyManagerOptions{Disabled: disabled}).Create(); ok {
made++
}
})
if !tt.HasText("No API keys") {
t.Fatal("no empty state")
}
_ = tt.Click("Create key")
tt.Type("x")
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEscape)
if made != 1 || !s.pending {
t.Fatal("escape left a pending create")
}
s.Fail("Key limit reached.")
tt.Frame()
if !tt.HasText("Key limit reached.") {
t.Fatalf("failure: %q", tt.Texts())
}
tt.Key(0, ui.KeyEscape)
disabled = true
tt.Frame()
_ = tt.Click("Create key")
if tt.HasText("New API key") {
t.Fatal("disabled manager opened the dialog")
}
}
func TestApiKeyManagerSnapshot(t *testing.T) {
snapshot(t, "api_key_manager", 560, 220, func(c *ui.Context) {
s := ui.Local(c.Root(), "s", func() ApiKeyManagerState { return ApiKeyManagerState{} })
ui.Column(c).Padding(16).Children(func() { ApiKeyManager(c, s, apiKeys, ApiKeyManagerOptions{}) })
})
}
func TestApiKeyManagerDisablingClosesEveryDialog(t *testing.T) {
for _, phase := range []string{"name", "pending", "secret", "revoke"} {
t.Run(phase, func(t *testing.T) {
var s ApiKeyManagerState
disabled := false
tt := accountTester(700, 600, func(c *ui.Context) {
ui.Column(c).Disabled(disabled).Children(func() { ApiKeyManager(c, &s, apiKeys, ApiKeyManagerOptions{}) })
})
if phase == "revoke" {
if err := tt.Click("Revoke Deploy bot"); err != nil {
t.Fatal(err)
}
} else {
if err := tt.Click("Create key"); err != nil {
t.Fatal(err)
}
if phase != "name" {
tt.Type("CI")
tt.Key(0, ui.KeyEnter)
}
if phase == "secret" {
s.Reveal("one-time-test-secret")
tt.Frame()
}
}
disabled = true
tt.Frame()
if tt.HasText("New API key") || tt.HasText("Copy your new key") || tt.HasText("Revoke Deploy bot?") || s.secret != "" {
t.Fatal("disabled manager retains an active dialog or secret")
}
})
}
}
func TestApiKeyManagerRemovedKeyCancelsConfirmation(t *testing.T) {
var s ApiKeyManagerState
keys := apiKeys
tt := accountTester(700, 600, func(c *ui.Context) { ApiKeyManager(c, &s, keys, ApiKeyManagerOptions{}) })
if err := tt.Click("Revoke Deploy bot"); err != nil {
t.Fatal(err)
}
keys = nil
tt.Frame()
if s.revoking != "" || tt.HasText("Apps using this key stop working at once.") {
t.Fatal("removed key keeps confirmation open")
}
}
func TestApiKeyManagerLargeDataBuildsVisibleRows(t *testing.T) {
items := make([]APIKey, 100000)
for i := range items {
items[i] = APIKey{ID: fmt.Sprint(i), Name: fmt.Sprintf("Key %d", i), Prefix: "public"}
}
var s ApiKeyManagerState
tt := accountTester(600, 500, func(c *ui.Context) { ApiKeyManager(c, &s, items, ApiKeyManagerOptions{}) })
if n := len(tt.Texts()); n > 100 {
t.Fatalf("built %d text elements for a bounded viewport", n)
}
}
func TestApiKeyManagerFailureSurvivesDisabledRequest(t *testing.T) {
for _, whileDisabled := range []bool{false, true} {
t.Run(fmt.Sprint(whileDisabled), func(t *testing.T) {
var s ApiKeyManagerState
disabled, requests := false, 0
tt := accountTester(700, 600, func(c *ui.Context) {
if _, ok := ApiKeyManager(c, &s, nil, ApiKeyManagerOptions{Disabled: disabled}).Create(); ok {
requests++
}
})
if err := tt.Click("Create key"); err != nil {
t.Fatal(err)
}
tt.Type("CI")
tt.Key(0, ui.KeyEnter)
disabled = true
tt.Frame()
if whileDisabled {
s.Fail("Create failed")
tt.Frame()
}
disabled = false
if !whileDisabled {
s.Fail("Create failed")
}
tt.Frame()
if !tt.HasText("Create failed") || s.name != "CI" || s.pending {
t.Fatal("disabled request lost its failure or draft")
}
tt.Key(0, ui.KeyEnter)
if requests != 2 || !s.pending {
t.Fatal("failed request cannot be retried")
}
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// CoachmarkStep uses an anchor built in the current frame.
type CoachmarkStep struct {
Anchor *ui.Element
Title, Body string
}
// CoachmarkResult reports tour completion or dismissal.
type CoachmarkResult struct {
Element *ui.Element
finished, dismissed bool
}
// Finished reports Done on the last step.
func (r CoachmarkResult) Finished() bool { return r.finished }
// Dismissed reports Escape, a click outside or Skip.
func (r CoachmarkResult) Dismissed() bool { return r.dismissed }
// Coachmark provides anchored tips and keyboard-accessible step navigation.
func Coachmark(c *ui.Context, open *bool, step *int, steps []CoachmarkStep) CoachmarkResult {
if len(steps) == 0 || *step < 0 || *step >= len(steps) {
panic(fmt.Sprintf("mujica: Coachmark step %d outside %d steps", *step, len(steps)))
}
var r CoachmarkResult
if !*open {
return r
}
cur := steps[*step]
if cur.Anchor == nil || cur.Title == "" {
panic("mujica: CoachmarkStep needs an Anchor and a Title")
}
if cur.Anchor.IsDisabled() {
*open = false
return r
}
k := Tokens(c)
d := Density(c)
if b := cur.Anchor.Bounds(); b.W > 0 {
const pad = 4
ui.Overlay(c, func() {
ui.Box(c).Absolute().Left(b.X-pad).Top(b.Y-pad).Size(b.W+2*pad, b.H+2*pad).PassThrough().Role(ui.RoleNone).
Border(2, k.AccentText).Radius(theme.CardRadius)
})
}
from := *step
r.Element = Popover(c, cur.Anchor, open, 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() {
*open, r.dismissed = false, true
}
if *step > 0 && Button(c, msg(c, "coachmark.back"), ButtonOptions{Variant: Secondary}).Clicked() {
*step--
}
last := *step == len(steps)-1
next := msg(c, "coachmark.next")
if last {
next = msg(c, "coachmark.done")
}
if Button(c, next, ButtonOptions{}).AutoFocus().Clicked() {
if last {
*open, r.finished = false, true
} else {
*step++
}
}
})
})
})
if !*open && !r.finished {
r.dismissed = true
}
if *step != from || !*open {
slog.Debug("mujica: coachmark step", "step", *step, "open", *open)
}
return r
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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})
})
*r = Coachmark(c, open, step, []CoachmarkStep{
{Anchor: a, Title: "Write here", Body: "Start a new letter."},
{Anchor: b, Title: "Keep it tidy", Body: "Archive finished threads."},
})
}
func TestCoachmarkStepsAndEscape(t *testing.T) {
open, step := true, 0
var r CoachmarkResult
done, dismissed := 0, 0
tt := accountTester(600, 400, func(c *ui.Context) {
coachmarkView(c, &open, &step, &r)
if r.Finished() {
done++
}
if r.Dismissed() {
dismissed++
}
})
tt.Frame()
if !tt.HasText("Write here") || !tt.HasText("1 of 2") {
t.Fatalf("first: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if step != 1 || !tt.HasText("Keep it tidy") {
t.Fatalf("next: %d", step)
}
_ = tt.Click("Done")
if done != 1 || open {
t.Fatal("done not reported")
}
open, step = true, 0
tt.Frame()
tt.Key(0, ui.KeyEscape)
if open || dismissed != 1 || done != 1 {
t.Fatalf("escape: open %v dismissed %d", open, dismissed)
}
}
func TestCoachmarkSnapshot(t *testing.T) {
snapshot(t, "coachmark", 420, 230, func(c *ui.Context) {
open := ui.Local(c.Root(), "open", func() bool { return true })
step := ui.Local(c.Root(), "step", func() int { return 0 })
var r CoachmarkResult
coachmarkView(c, open, step, &r)
})
}
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})
Coachmark(c, &open, &step, []CoachmarkStep{{Anchor: a, Title: "Write here"}})
})
disabled = true
tt.Frame()
if open || tt.HasText("Write here") {
t.Fatal("disabled anchor retains coachmark")
}
}
func TestCoachmarkInvalidStep(t *testing.T) {
open, step := true, 1
accountPanics(t, func(c *ui.Context) { Coachmark(c, &open, &step, []CoachmarkStep{{Title: "Only step"}}) })
}
+53
View File
@@ -0,0 +1,53 @@
# 461 SettingsLayout
设置布局。
## 用途
设置页框架:左侧分区导航(Sidebar),右侧显示当前分区;配套 `SettingsSection` 分节与 `SettingsRow` 设置行(标签、说明、控件)。
## 最小示例
```go
page := ""
sound := true
pages := []mujica.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SettingsLayout(c, &page, pages, mujica.SettingsLayoutOptions{}, func(id string) {
mujica.SettingsSection(c, "Notifications", func() {
mujica.SettingsRow(c, "Sound", mujica.SettingsRowOptions{ID: "sound"}, func() *ui.Element {
return mujica.Switch(c, &sound, "", mujica.SwitchOptions{})
})
})
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `page` | `*string` | 必填 | 当前分区 ID;空值取第一个,未知 ID panic。 |
| `pages` | `[]SettingsPage` | 必填 | ID、Label、Icon。 |
| `Title` | `string` | 内置“Settings” | 导航标题。 |
| `content` | `func(id string)` | 必填 | 构建分区内容。 |
| `SettingsRow control` | `func() *ui.Element` | 必填 | 返回控件;行标签与说明关联到它。 |
| `SettingsRowOptions.ID` | `string` | "" | 供 SettingsSearch 跳转定位。 |
## 状态
导航选中、行悬停、被搜索定位的行短暂高亮(减少动画时不高亮)。
## 事件
`ChoiceResult.Changed()` 在切换分区时为 true。
## 键盘操作
导航沿用 Sidebar 键盘操作;Tab 进入内容区各控件;点击行文字聚焦其控件。
## 限制
- 内容区独立滚动,最大宽度 720 DIP。
+48
View File
@@ -0,0 +1,48 @@
# 462 SettingsSearch
设置搜索。
## 用途
按标签、说明与关键词搜索设置项,高亮匹配片段;选中后切到对应分区并把该设置行滚入视野、聚焦其控件。
## 最小示例
```go
query, page := "", "general"
entries := []mujica.SettingEntry{{ID: "sound", Page: "general", Label: "Sound", Keywords: []string{"audio"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if e, ok := mujica.SettingsSearch(c, &query, entries, mujica.SettingsSearchOptions{}).Chosen(); ok {
page = e.Page
}
fmt.Println("page", page)
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `query` | `*string` | 必填 | 搜索文字。 |
| `entries` | `[]SettingEntry` | 必填 | ID(对应 SettingsRow ID)、Page、Label、Description、Keywords。 |
| `Limit` | `int` | 8 | 最多显示条数。 |
## 状态
无输入、有结果(当前项底色)、无结果。
## 事件
`SettingsSearchResult.Chosen()` 返回选中的条目;调用方据此设置分区。
## 键盘操作
在搜索框中 ↑/↓ 移动当前项,Enter 跳转;也可点击结果。
## 限制
大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。
- 匹配不区分大小写,按子串查找。
+41
View File
@@ -0,0 +1,41 @@
# 463 ThemeSelector
主题选择。
## 用途
以三张预览卡选择跟随系统、浅色或深色;预览用对应主题的设计变量绘制。
## 最小示例
```go
mode := mujica.System
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
mujica.ThemeSelector(c, &mode, mujica.ThemeSelectorOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `mode` | `*Mode` | 必填 | System、Light 或 Dark;其他值 panic。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、悬停、聚焦、选中(选中底色、边框与勾选)、禁用。
## 事件
`ChoiceResult.Changed()`。
## 键盘操作
Tab 进入单选组,←/→ 移动,Space 选择。
## 限制
- 无。
@@ -0,0 +1,42 @@
# 464 AccentColorPicker
主色选择。
## 用途
从候选色中选择主色;承载浅色文字对比度不足 4.5:1 的候选带警示标记,选中时给出提示。
## 最小示例
```go
accent := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AccentColorPicker(c, &accent, mujica.AccentColorPickerOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `color` | `*ui.Color` | 必填 | 当前主色。 |
| `Candidates` | `[]ui.Color` | 内置 8 色 | 候选色。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、选中(外框与勾选)、对比度不足(警示图标与说明)、禁用。
## 事件
`ChoiceResult.Changed()`。
## 键盘操作
Tab 进入单选组,←/→ 移动,Space 选择。
## 限制
- 对比度按 OnAccent 文字计算;只标记,不阻止选择。
+46
View File
@@ -0,0 +1,46 @@
# 465 ShortcutRecorder
快捷键录制。
## 用途
点击后录制下一次按下的组合键;与已占用的快捷键冲突时提示并保留原值;按平台显示修饰键。
## 最小示例
```go
palette := mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ShortcutRecorder(c, &palette, mujica.ShortcutRecorderOptions{
Label: "Command palette",
Taken: []mujica.ShortcutBinding{{Action: "Save", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}},
})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `value` | `*Shortcut` | 必填 | Mods 与 Key;零值表示未设置。 |
| `Label` | `string` | 必填 | 动作名称。 |
| `Taken` | `[]ShortcutBinding` | nil | 已占用的快捷键。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
显示、录制中、冲突提示、缺少修饰键提示、禁用。
## 事件
`ChoiceResult.Changed()` 在录到新快捷键时为 true。
## 键盘操作
Enter/Space 开始录制;Escape 取消;Tab 离开。字母、数字等须带 Ctrl、Alt 或 Command,F1–F12 可单独使用。
## 限制
- 系统或应用菜单先拦截的组合键(如 Cmd+Q)无法录制。
@@ -0,0 +1,47 @@
# 466 KeyboardShortcutsList
快捷键列表。
## 用途
分组显示快捷键表,可按动作名或按键搜索。
## 最小示例
```go
query := ""
groups := []mujica.ShortcutGroup{{Title: "General", Bindings: []mujica.ShortcutBinding{
{Action: "Command palette", Shortcut: mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.KeyboardShortcutsList(c, &query, groups, mujica.KeyboardShortcutsListOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `query` | `*string` | 必填 | 搜索文字。 |
| `groups` | `[]ShortcutGroup` | 必填 | Title 与 Bindings。 |
| `Label` | `string` | 内置“Search shortcuts” | 搜索框名称。 |
## 状态
全部、筛选、无结果。
## 事件
无。
## 键盘操作
Tab 进入搜索框;每行读出动作与按键。
## 限制
大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。
- 按键按平台显示,macOS 使用符号。
+47
View File
@@ -0,0 +1,47 @@
# 467 LoginForm
登录表单。
## 用途
账号、密码与“记住我”;提交中禁用输入;显示调用方给出的错误(含频率限制、锁定)。密码在私有临时状态中编辑,经提交回调传出,完成后清除;账号校验由调用方完成。
## 最小示例
```go
var state mujica.LoginFormState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LoginForm(c, &state, mujica.LoginFormOptions{OnSubmit: func(account, password string, remember bool) {
// Submit credentials through the caller-provided auth service.
state.EndSubmit("")
}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `s` | `*LoginFormState` | 必填 | Account、Remember 可读写;密码不可读取。 |
| `OnSubmit` | `func(account, password string, remember bool)` | 必填 | 唯一接收凭据的出口。 |
| `AccountLabel` | `string` | 内置“Email or username” | 账号标签。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、必填错误、提交中、失败(错误文字)、禁用。
## 事件
`OnSubmit` 每次提交调用一次;完成后调用 `s.EndSubmit(err)`,密码随即清除。
## 键盘操作
Tab 顺序:账号、密码、显示密码、记住我、登录;任一字段中 Enter 提交;缺项时聚焦第一个空字段。
## 限制
- 凭据只经调用方回调传出;密码、验证码与密钥不会写入日志、Label 或 Description。
- 提交失败后需重新输入密码。
+44
View File
@@ -0,0 +1,44 @@
# 468 OAuthButtons
第三方登录按钮。
## 用途
按调用方提供的名称显示“使用 X 继续”按钮组;不使用商标图形。
## 最小示例
```go
providers := []mujica.OAuthProvider{{ID: "sso", Name: "Company SSO"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.OAuthButtons(c, providers, mujica.OAuthButtonsOptions{}).Chosen(); ok {
fmt.Println("start sign-in with", id)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `providers` | `[]OAuthProvider` | 必填 | ID、Name 必填;Icon 可选,应为非商标图形。 |
| `Pending` | `string` | "" | 正在进行的提供方:显示加载,其余禁用。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、进行中、禁用。
## 事件
`OAuthButtonsResult.Chosen()` 返回提供方 ID。
## 键盘操作
Tab 在按钮间移动,Enter/Space 选择。
## 限制
- 登录流程由调用方完成。
+48
View File
@@ -0,0 +1,48 @@
# 469 TwoFactorInput
两步验证码。
## 用途
六位验证码输入(基于 PinInput),支持整段粘贴;重新发送带倒计时;显示调用方错误。
## 最小示例
```go
code := ""
resendAt := time.Now().Add(30 * time.Second)
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TwoFactorInput(c, &code, mujica.TwoFactorInputOptions{ResendAt: resendAt})
if r.Completed() {
fmt.Println("verify the code")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `code` | `*string` | 必填 | 验证码数字。 |
| `ResendAt` | `time.Time` | 零值 | 此时间前重新发送按钮倒计时并禁用。 |
| `Error` | `string` | "" | 调用方错误,如过期。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
输入中、完成、错误、倒计时、可重发、禁用。
## 事件
`TwoFactorResult.Completed()` 在填满第六位时;`Resend()` 请求重发。
## 键盘操作
数字键填格,Backspace 删除,Cmd/Ctrl+V 粘贴;Tab 到重新发送按钮。
## 限制
- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。
- 倒计时每秒刷新一次,结束后不再重绘。
+48
View File
@@ -0,0 +1,48 @@
# 470 ProfileEditor
个人资料编辑。
## 用途
头像、名称与简介;与已保存版本不同时显示未保存标记;保存、放弃与更换头像由调用方处理。
## 最小示例
```go
saved := mujica.Profile{Name: "Sakiko", Bio: "Keyboard."}
profile := saved
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ProfileEditor(c, &profile, mujica.ProfileEditorOptions{Saved: saved}).Save() {
saved = profile
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `p` | `*Profile` | 必填 | Name、Bio。 |
| `Saved` | `Profile` | 零值 | 已保存的资料。 |
| `Avatar` | `*ui.Bitmap` | nil | 头像;nil 显示首字母。 |
| `BioLimit` | `int` | 160 | 可保存的简介字数上限;超限保留输入并阻止保存。 |
| `Saving` | `bool` | false | 保存中,阻止编辑。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
已保存、未保存(菱形与文字)、名称为空、简介超限、保存中、禁用。
## 事件
`Save()`、`ChangeAvatar()`、`Discarded()`。
## 键盘操作
Tab 依次经过更换头像、名称、简介、放弃、保存。
## 限制
- 头像选择与上传由调用方完成,可配合 FilePicker。
+47
View File
@@ -0,0 +1,47 @@
# 471 AccountSwitcher
账户切换。
## 用途
显示当前账户,菜单中切换已登录账户或添加账户。
## 最小示例
```go
current := "a"
accounts := []mujica.Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AccountSwitcher(c, &current, accounts, mujica.AccountSwitcherOptions{}).Add() {
fmt.Println("sign in another account")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `current` | `*string` | 必填 | 当前账户 ID,须在列表中。 |
| `accounts` | `[]Account` | 必填 | ID、Name 必填;Email、Avatar 可选。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
关闭、展开、当前账户勾选、禁用。
## 事件
`Changed()` 切换;`Add()` 请求添加账户。
## 键盘操作
Enter/Space/↓ 打开菜单,方向键移动,Enter 选择,Escape 关闭。
## 限制
大集合使用虚拟列表,仅构建当前可见行。
- 菜单项只显示文字,头像只在按钮上。
+45
View File
@@ -0,0 +1,45 @@
# 472 WorkspaceSwitcher
工作区切换。
## 用途
显示当前工作区,弹层中搜索并切换。
## 最小示例
```go
current := "ave"
spaces := []mujica.Workspace{{ID: "ave", Name: "Ave Mujica"}, {ID: "mygo", Name: "MyGO!!!!!"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkspaceSwitcher(c, &current, spaces, mujica.WorkspaceSwitcherOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `current` | `*string` | 必填 | 当前工作区 ID。 |
| `spaces` | `[]Workspace` | 必填 | ID、Name 必填,Detail 可选。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
关闭、展开、搜索、无结果、当前项勾选、禁用。
## 事件
`ChoiceResult.Changed()`。
## 键盘操作
打开后焦点在搜索框;↑/↓ 移动,Enter 切换,Escape 关闭。
## 限制
大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。
- 无。
+46
View File
@@ -0,0 +1,46 @@
# 473 UserMenu
用户菜单。
## 用途
头像按钮打开菜单:在线状态、设置与退出登录。头像角标以形状与颜色共同表示状态。
## 最小示例
```go
status := mujica.PresenceOnline
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.UserMenu(c, &status, mujica.UserMenuOptions{Name: "Sakiko Togawa"}).SignOut() {
fmt.Println("sign out")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `status` | `*UserStatus` | 必填 | PresenceOnline、PresenceAway、PresenceBusy、PresenceInvisible。 |
| `Name` | `string` | 必填 | 用户名。 |
| `Email` | `string` | "" | 邮箱。 |
| `Avatar` | `*ui.Bitmap` | nil | 头像。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
关闭、展开、四种状态、禁用。
## 事件
`StatusChanged()`、`Settings()`、`SignOut()`。
## 键盘操作
Enter/Space/↓ 打开,方向键移动,Enter 选择,Escape 关闭。
## 限制
- 按钮名称包含用户名与状态。
+45
View File
@@ -0,0 +1,45 @@
# 474 SessionList
登录会话。
## 用途
列出已登录设备、位置与最近活跃时间;可注销单个会话或经确认注销其他全部会话。
## 最小示例
```go
sessions := []mujica.Session{{ID: "s1", Device: "MacBook Pro", Current: true}, {ID: "s2", Device: "iPhone", Location: "Osaka"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.SessionList(c, sessions, mujica.SessionListOptions{}).Revoked(); ok {
fmt.Println("sign out", id)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `sessions` | `[]Session` | 必填 | ID、Device 必填;不包含会话令牌。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
本设备标记、最近活跃未知、确认对话框、禁用。
## 事件
`Revoked()` 返回会话 ID;`OthersRevoked()` 在确认后为 true。
## 键盘操作
Tab 到各注销按钮;确认框默认聚焦取消。
## 限制
大集合使用虚拟列表,仅构建当前可见行。
- 注销由调用方执行。
+51
View File
@@ -0,0 +1,51 @@
# 475 ApiKeyManager
API 密钥管理。
## 用途
密钥列表只显示前缀与掩码;创建后明文只显示一次并可复制,关闭即从内存清除;撤销需确认。
## 最小示例
```go
var state mujica.ApiKeyManagerState
keys := []mujica.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if name, ok := mujica.ApiKeyManager(c, &state, keys, mujica.ApiKeyManagerOptions{}).Create(); ok {
fmt.Println("create", name)
state.Reveal("secret from your key service")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `s` | `*ApiKeyManagerState` | 必填 | 对话框与一次性明文。 |
| `keys` | `[]APIKey` | 必填 | ID、Name、Prefix 必填;不含明文。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
列表、空列表、输入名称、创建中、显示明文、失败、撤销确认、禁用。
## 事件
`Create()` 返回新密钥名称:调用方生成后调用 `s.Reveal(secret)` 或 `s.Fail(msg)`;`Revoked()` 返回确认撤销的 ID。
## 键盘操作
Tab 到创建与撤销按钮;对话框中 Enter 创建;复制按钮写入剪贴板并播报“已复制”。
## 限制
大集合使用虚拟列表,仅构建当前可见行。
- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。
- 明文作为可选择文字显示一次,屏幕阅读器可读出文字本身。
- 创建进行中等待调用方结果;禁用关闭全部弹层并清除已显示明文。
- 待处理请求跨禁用保留;失败后恢复名称与错误,支持重试。
+42
View File
@@ -0,0 +1,42 @@
# 476 UsageQuota
用量配额。
## 用途
每项资源的用量与上限进度条;接近与超出上限以图标与文字提示;显示重置时间。
## 最小示例
```go
items := []mujica.QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UsageQuota(c, items, mujica.UsageQuotaOptions{ResetAt: time.Now().Add(72 * time.Hour)})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `items` | `[]QuotaItem` | 必填 | Label、Used、Limit(>0)、Unit。 |
| `ResetAt` | `time.Time` | 零值 | 重置时间;一天内显示小时,否则显示日期。 |
| `Warn` | `float64` | 0.8 | 达到该比例即提示接近上限。 |
## 状态
正常、接近上限、已达上限。
## 事件
无。
## 键盘操作
不获取焦点;每项以计量角色读出。
## 限制
- 无。
+45
View File
@@ -0,0 +1,45 @@
# 477 OnboardingWizard
引导向导。
## 用途
多步引导:进度条、步骤内容、上一步、下一步、跳过与完成。
## 最小示例
```go
step := 0
steps := []mujica.WizardStep{{Title: "Welcome"}, {Title: "Pick a theme"}, {Title: "Done"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.OnboardingWizard(c, &step, steps, mujica.OnboardingWizardOptions{}).Finished() {
fmt.Println("onboarded")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `step` | `*int` | 必填 | 当前步骤,越界 panic。 |
| `steps` | `[]WizardStep` | 必填 | Title 必填;Description、Content、Ready。 |
| `NoSkip` | `bool` | false | 不显示跳过。 |
## 状态
各步骤、未就绪(下一步禁用)、最后一步(完成)。
## 事件
`Finished()`、`Skipped()`。
## 键盘操作
下一步按钮自动聚焦;Tab 在按钮与内容间移动。
## 限制
- 无。
+43
View File
@@ -0,0 +1,43 @@
# 478 Coachmark
功能提示。
## 用途
在锚点元素旁显示提示,锚点加框;多步导航;Escape 或点击外部关闭。
## 最小示例
```go
open, step := true, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
compose := mujica.Button(c, "Compose", mujica.ButtonOptions{})
mujica.Coachmark(c, &open, &step, []mujica.CoachmarkStep{{Anchor: compose, Title: "Write here", Body: "Start a new letter."}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | 必填 | 是否显示。 |
| `step` | `*int` | 必填 | 当前步骤。 |
| `steps` | `[]CoachmarkStep` | 必填 | Anchor(本帧已构建的元素)、Title 必填,Body 可选。 |
## 状态
显示、各步骤、最后一步(完成)、关闭。
## 事件
`Finished()`;`Dismissed()` 在 Escape、点击外部或跳过时为 true。
## 键盘操作
下一步按钮自动聚焦;Escape 关闭。
## 限制
- 弹层沿用 Popover 定位,空间不足时翻转到锚点上方。
+44
View File
@@ -0,0 +1,44 @@
# 479 WhatsNewDialog
更新内容。
## 用途
以带饰边的对话框列出各版本的更新(新增、改进、修复),提供“不再显示”。
## 最小示例
```go
open, hide := true, false
releases := []mujica.Release{{Version: "1.1", Changes: []mujica.ReleaseChange{{Kind: mujica.ChangeNew, Text: "500 components."}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WhatsNewDialog(c, &open, &hide, releases, mujica.WhatsNewDialogOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `open` | `*bool` | 必填 | 是否显示。 |
| `hide` | `*bool` | 必填 | “不再显示”的值,由调用方保存。 |
| `releases` | `[]Release` | 必填 | Version 必填;Date、Changes。 |
| `Title` | `string` | 内置“What's new” | 标题。 |
## 状态
显示、勾选不再显示。
## 事件
`ChoiceResult.Changed()` 在“不再显示”变化时。
## 键盘操作
“知道了”自动聚焦;Escape 关闭。
## 限制
- 无。
+46
View File
@@ -0,0 +1,46 @@
# 480 FeedbackWidget
反馈组件。
## 用途
选择反馈类型、填写内容、附件插槽,提交一次后等待调用方完成。基于 Form。
## 最小示例
```go
var state mujica.FeedbackWidgetState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if f, ok := mujica.FeedbackWidget(c, &state, mujica.FeedbackWidgetOptions{}).Submitted(); ok {
fmt.Println("send", f.Kind)
state.EndSubmit("")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `s` | `*FeedbackWidgetState` | 必填 | Draft 与 Form。 |
| `Attachments` | `func()` | nil | 截图或文件插槽。 |
| `MinLength` | `int` | 10 | 最少字数。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
填写、过短错误、提交中、失败、禁用。提交中锁定类型、正文与附件控件,失败保留原稿。
## 事件
`Submitted()` 返回反馈;完成后调用 `s.EndSubmit(err)`,成功时清空内容。
## 键盘操作
类型分段用方向键切换;Tab 到内容与按钮。
## 限制
- 截图采集由调用方完成。
+240
View File
@@ -0,0 +1,240 @@
package mujica_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleSettingsLayout() {
page := ""
sound := true
pages := []mujica.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SettingsLayout(c, &page, pages, mujica.SettingsLayoutOptions{}, func(id string) {
mujica.SettingsSection(c, "Notifications", func() {
mujica.SettingsRow(c, "Sound", mujica.SettingsRowOptions{ID: "sound"}, func() *ui.Element {
return mujica.Switch(c, &sound, "", mujica.SwitchOptions{})
})
})
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSettingsSearch() {
query, page := "", "general"
entries := []mujica.SettingEntry{{ID: "sound", Page: "general", Label: "Sound", Keywords: []string{"audio"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if e, ok := mujica.SettingsSearch(c, &query, entries, mujica.SettingsSearchOptions{}).Chosen(); ok {
page = e.Page
}
fmt.Println("page", page)
}
ui.Render(view, 400, 300, 1)
}
func ExampleThemeSelector() {
mode := mujica.System
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
mujica.ThemeSelector(c, &mode, mujica.ThemeSelectorOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAccentColorPicker() {
accent := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AccentColorPicker(c, &accent, mujica.AccentColorPickerOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleShortcutRecorder() {
palette := mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ShortcutRecorder(c, &palette, mujica.ShortcutRecorderOptions{
Label: "Command palette",
Taken: []mujica.ShortcutBinding{{Action: "Save", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}},
})
}
ui.Render(view, 400, 300, 1)
}
func ExampleKeyboardShortcutsList() {
query := ""
groups := []mujica.ShortcutGroup{{Title: "General", Bindings: []mujica.ShortcutBinding{
{Action: "Command palette", Shortcut: mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.KeyboardShortcutsList(c, &query, groups, mujica.KeyboardShortcutsListOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleLoginForm() {
var state mujica.LoginFormState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LoginForm(c, &state, mujica.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 := []mujica.OAuthProvider{{ID: "sso", Name: "Company SSO"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.OAuthButtons(c, providers, mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.TwoFactorInput(c, &code, mujica.TwoFactorInputOptions{ResendAt: resendAt})
if r.Completed() {
fmt.Println("verify the code")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleProfileEditor() {
saved := mujica.Profile{Name: "Sakiko", Bio: "Keyboard."}
profile := saved
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ProfileEditor(c, &profile, mujica.ProfileEditorOptions{Saved: saved}).Save() {
saved = profile
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAccountSwitcher() {
current := "a"
accounts := []mujica.Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AccountSwitcher(c, &current, accounts, mujica.AccountSwitcherOptions{}).Add() {
fmt.Println("sign in another account")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleWorkspaceSwitcher() {
current := "ave"
spaces := []mujica.Workspace{{ID: "ave", Name: "Ave Mujica"}, {ID: "mygo", Name: "MyGO!!!!!"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkspaceSwitcher(c, &current, spaces, mujica.WorkspaceSwitcherOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleUserMenu() {
status := mujica.PresenceOnline
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.UserMenu(c, &status, mujica.UserMenuOptions{Name: "Sakiko Togawa"}).SignOut() {
fmt.Println("sign out")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSessionList() {
sessions := []mujica.Session{{ID: "s1", Device: "MacBook Pro", Current: true}, {ID: "s2", Device: "iPhone", Location: "Osaka"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.SessionList(c, sessions, mujica.SessionListOptions{}).Revoked(); ok {
fmt.Println("sign out", id)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleApiKeyManager() {
var state mujica.ApiKeyManagerState
keys := []mujica.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if name, ok := mujica.ApiKeyManager(c, &state, keys, mujica.ApiKeyManagerOptions{}).Create(); ok {
fmt.Println("create", name)
state.Reveal("secret from your key service")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleUsageQuota() {
items := []mujica.QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UsageQuota(c, items, mujica.UsageQuotaOptions{ResetAt: time.Now().Add(72 * time.Hour)})
}
ui.Render(view, 400, 300, 1)
}
func ExampleOnboardingWizard() {
step := 0
steps := []mujica.WizardStep{{Title: "Welcome"}, {Title: "Pick a theme"}, {Title: "Done"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.OnboardingWizard(c, &step, steps, mujica.OnboardingWizardOptions{}).Finished() {
fmt.Println("onboarded")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCoachmark() {
open, step := true, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
compose := mujica.Button(c, "Compose", mujica.ButtonOptions{})
mujica.Coachmark(c, &open, &step, []mujica.CoachmarkStep{{Anchor: compose, Title: "Write here", Body: "Start a new letter."}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleWhatsNewDialog() {
open, hide := true, false
releases := []mujica.Release{{Version: "1.1", Changes: []mujica.ReleaseChange{{Kind: mujica.ChangeNew, Text: "500 components."}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WhatsNewDialog(c, &open, &hide, releases, mujica.WhatsNewDialogOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFeedbackWidget() {
var state mujica.FeedbackWidgetState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if f, ok := mujica.FeedbackWidget(c, &state, mujica.FeedbackWidgetOptions{}).Submitted(); ok {
fmt.Println("send", f.Kind)
state.EndSubmit("")
}
}
ui.Render(view, 400, 300, 1)
}
+153 -1
View File
@@ -1,3 +1,155 @@
package main
var accountPages = []page{}
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const accountCategory = "Settings & Account"
var accountPages = []page{
{ID: "461", Name: "SettingsLayout", ZhName: "设置布局", Category: accountCategory, Code: accountCode["SettingsLayout"], Demo: accountSettingsDemo,
Keys: "Arrows move in the navigation; Tab enters the page; clicking a row's text focuses its control."},
{ID: "462", Name: "SettingsSearch", ZhName: "设置搜索", Category: accountCategory, Code: accountCode["SettingsSearch"], Demo: accountSearchDemo,
Keys: "Type to search; Up and Down move; Enter jumps to the setting and focuses it."},
{ID: "463", Name: "ThemeSelector", ZhName: "主题选择", Category: accountCategory, Code: accountCode["ThemeSelector"], Demo: accountThemeDemo,
Keys: "Left and Right move between cards; Space picks."},
{ID: "464", Name: "AccentColorPicker", ZhName: "主色选择", Category: accountCategory, Code: accountCode["AccentColorPicker"], Demo: accountAccentDemo,
Keys: "Left and Right move between swatches; Space picks."},
{ID: "465", Name: "ShortcutRecorder", ZhName: "快捷键录制", Category: accountCategory, Code: accountCode["ShortcutRecorder"], Demo: accountRecorderDemo,
Keys: "Enter or Space records; press the combination; Escape cancels."},
{ID: "466", Name: "KeyboardShortcutsList", ZhName: "快捷键列表", Category: accountCategory, Code: accountCode["KeyboardShortcutsList"], Demo: accountShortcutsDemo,
Keys: "Type in the search field to filter by action or key."},
{ID: "467", Name: "LoginForm", ZhName: "登录表单", Category: accountCategory, Code: accountCode["LoginForm"], Demo: accountLoginDemo,
Keys: "Enter in any field signs in; an empty field takes the focus."},
{ID: "468", Name: "OAuthButtons", ZhName: "第三方登录按钮", Category: accountCategory, Code: accountCode["OAuthButtons"], Demo: accountOAuthDemo,
Keys: "Tab between providers; Enter or Space continues."},
{ID: "469", Name: "TwoFactorInput", ZhName: "两步验证码", Category: accountCategory, Code: accountCode["TwoFactorInput"], Demo: accountTwoFactorDemo,
Keys: "Type or paste digits; Backspace clears; Tab reaches Resend."},
{ID: "470", Name: "ProfileEditor", ZhName: "个人资料编辑", Category: accountCategory, Code: accountCode["ProfileEditor"], Demo: accountProfileDemo,
Keys: "Tab through picture, name, bio, Discard and Save."},
{ID: "471", Name: "AccountSwitcher", ZhName: "账户切换", Category: accountCategory, Code: accountCode["AccountSwitcher"], Demo: accountSwitcherDemo,
Keys: "Enter, Space or Down opens; arrows move; Enter chooses; Escape closes."},
{ID: "472", Name: "WorkspaceSwitcher", ZhName: "工作区切换", Category: accountCategory, Code: accountCode["WorkspaceSwitcher"], Demo: accountWorkspaceDemo,
Keys: "Open, type to search, Up and Down move, Enter switches, Escape closes."},
{ID: "473", Name: "UserMenu", ZhName: "用户菜单", Category: accountCategory, Code: accountCode["UserMenu"], Demo: accountUserMenuDemo,
Keys: "Enter, Space or Down opens; arrows move; Enter chooses."},
{ID: "474", Name: "SessionList", ZhName: "登录会话", Category: accountCategory, Code: accountCode["SessionList"], Demo: accountSessionsDemo,
Keys: "Tab to each Sign out; the confirmation focuses Cancel."},
{ID: "475", Name: "ApiKeyManager", ZhName: "API 密钥管理", Category: accountCategory, Code: accountCode["ApiKeyManager"], Demo: accountKeysDemo,
Keys: "Enter in the name field creates; Copy key copies; Revoke asks first."},
{ID: "476", Name: "UsageQuota", ZhName: "用量配额", Category: accountCategory, Code: accountCode["UsageQuota"], Demo: accountQuotaDemo,
Keys: "Display only; each bar reads as a meter."},
{ID: "477", Name: "OnboardingWizard", ZhName: "引导向导", Category: accountCategory, Code: accountCode["OnboardingWizard"], Demo: accountWizardDemo,
Keys: "Next has the focus; Tab reaches Back and Skip."},
{ID: "478", Name: "Coachmark", ZhName: "功能提示", Category: accountCategory, Code: accountCode["Coachmark"], Demo: accountCoachDemo,
Keys: "Next has the focus; Escape closes the tour."},
{ID: "479", Name: "WhatsNewDialog", ZhName: "更新内容", Category: accountCategory, Code: accountCode["WhatsNewDialog"], Demo: accountWhatsNewDemo,
Keys: "Got it has the focus; Escape closes."},
{ID: "480", Name: "FeedbackWidget", ZhName: "反馈组件", Category: accountCategory, Code: accountCode["FeedbackWidget"], Demo: accountFeedbackDemo,
Keys: "Arrows switch the type; Tab reaches the message and buttons."},
}
type accountSettingsState struct {
page, query string
sound, digest, report bool
}
var accountSettingPages = []mujica.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
var accountSettingEntries = []mujica.SettingEntry{
{ID: "sound", Page: "general", Label: "Sound", Description: "Chime on new messages.", Keywords: []string{"audio", "notification"}},
{ID: "digest", Page: "general", Label: "Weekly digest", Description: "A summary every Monday.", Keywords: []string{"email"}},
{ID: "report", Page: "privacy", Label: "Usage reports", Description: "Send anonymous usage."},
}
// accountSettingsPage builds the demo pages shared by 461 and 462.
func accountSettingsPage(c *ui.Context, d *accountSettingsState, log func(string)) {
ui.Box(c).Width(900).Height(320).Children(func() {
if mujica.SettingsLayout(c, &d.page, accountSettingPages, mujica.SettingsLayoutOptions{}, func(id string) {
switch id {
case "general":
mujica.SettingsSection(c, "Notifications", func() {
accountSwitchRow(c, "sound", "Sound", "Chime on new messages.", &d.sound, log)
accountSwitchRow(c, "digest", "Weekly digest", "A summary every Monday.", &d.digest, log)
})
case "privacy":
mujica.SettingsSection(c, "Data", func() {
accountSwitchRow(c, "report", "Usage reports", "Send anonymous usage.", &d.report, log)
})
}
}).Changed() {
log("page " + d.page)
}
})
}
// accountSwitchRow is a settings row with a switch.
func accountSwitchRow(c *ui.Context, id, label, desc string, v *bool, log func(string)) {
mujica.SettingsRow(c, label, mujica.SettingsRowOptions{ID: id, Description: desc}, func() *ui.Element {
sw := mujica.Switch(c, v, "", mujica.SwitchOptions{})
if sw.Changed() {
log(fmt.Sprintf("%s %v", label, *v))
}
return sw
})
}
func accountSettingsDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "461", func() accountSettingsState { return accountSettingsState{sound: true} })
accountSettingsPage(c, d, log)
}
func accountSearchDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "462", func() accountSettingsState { return accountSettingsState{sound: true} })
ui.Box(c).Width(320).Children(func() {
if e, ok := mujica.SettingsSearch(c, &d.query, accountSettingEntries, mujica.SettingsSearchOptions{}).Chosen(); ok {
d.page = e.Page
log("jump to " + e.ID)
}
})
accountSettingsPage(c, d, log)
}
type accountThemeState struct {
mode mujica.Mode
disabled bool
}
func accountThemeDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "463", func() accountThemeState { return accountThemeState{} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
if mujica.ThemeSelector(c, &d.mode, mujica.ThemeSelectorOptions{Disabled: d.disabled}).Changed() {
log("theme " + d.mode.String())
}
}
type accountAccentState struct {
color ui.Color
disabled bool
}
func accountAccentDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "464", func() accountAccentState { return accountAccentState{color: ui.Hex("#7D2034")} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
if mujica.AccentColorPicker(c, &d.color, mujica.AccentColorPickerOptions{Disabled: d.disabled}).Changed() {
log(fmt.Sprintf("accent %v", d.color))
}
mujica.Button(c, "Preview button", mujica.ButtonOptions{}).Background(d.color)
}
func accountToggles(c *ui.Context, log func(string), labels []string, values ...*bool) {
before := make([]bool, len(values))
for i, v := range values {
before[i] = *v
}
formToggles(c, labels, values...)
for i, v := range values {
if before[i] != *v {
log(fmt.Sprintf("%s %v", labels[i], *v))
}
}
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
type accountRecorderState struct {
palette, zen mujica.Shortcut
disabled bool
}
var accountTaken = []mujica.ShortcutBinding{{Action: "Save", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}}
func accountRecorderDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "465", func() accountRecorderState {
return accountRecorderState{palette: mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}}
})
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
ui.Text(c, "Cmd/Ctrl+S is taken by Save.")
for _, r := range []struct {
name string
v *mujica.Shortcut
}{{"Command palette", &d.palette}, {"Zen mode", &d.zen}} {
if mujica.ShortcutRecorder(c, r.v, mujica.ShortcutRecorderOptions{Label: r.name, Taken: accountTaken, Disabled: d.disabled}).Changed() {
log(fmt.Sprintf("%s = %s", r.name, r.v.String()))
}
}
}
type accountShortcutsState struct{ query string }
func accountShortcutsDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "466", func() accountShortcutsState { return accountShortcutsState{} })
groups := []mujica.ShortcutGroup{
{Title: "General", Bindings: []mujica.ShortcutBinding{
{Action: "Command palette", Shortcut: mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
{Action: "Settings", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyComma}},
{Action: "Save", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}},
}},
{Title: "Editing", Bindings: []mujica.ShortcutBinding{
{Action: "Duplicate line", Shortcut: mujica.Shortcut{Mods: ui.Alt | ui.Shift, Key: ui.KeyDown}},
{Action: "Toggle comment", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeySlash}},
{Action: "Zen mode"},
}},
}
before := d.query
ui.Box(c).Width(480).Children(func() { mujica.KeyboardShortcutsList(c, &d.query, groups, mujica.KeyboardShortcutsListOptions{}) })
if d.query != before {
log(fmt.Sprintf("search %q", d.query))
}
}
type accountLoginState struct {
form mujica.LoginFormState
disabled, reject bool
attempts int
}
func accountLoginDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "467", func() accountLoginState { return accountLoginState{reject: true} })
done := *ui.Local(c.Root(), "467.done", func() chan string { return make(chan string, 1) })
select {
case e := <-done:
d.form.EndSubmit(e)
log(fmt.Sprintf("sign-in finished, error %q", e))
default:
}
accountToggles(c, log, []string{"Disabled", "Server rejects"}, &d.disabled, &d.reject)
mujica.LoginForm(c, &d.form, mujica.LoginFormOptions{Disabled: d.disabled, OnSubmit: func(account, password string, remember bool) {
// Credentials stay in the caller's sign-in request.
d.attempts++
log(fmt.Sprintf("submit %q, remember %v", account, remember))
fail := ""
switch {
case d.attempts >= 3:
fail = "Too many attempts. Try again in 5 minutes."
case d.reject:
fail = "Wrong account or password."
}
go func() {
time.Sleep(700 * time.Millisecond)
done <- fail
c.Invalidate()
}()
}})
}
type accountOAuthState struct {
pending string
disabled bool
}
func accountOAuthDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "468", func() accountOAuthState { return accountOAuthState{} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
providers := []mujica.OAuthProvider{{ID: "git", Name: "Git host"}, {ID: "sso", Name: "Company SSO"}, {ID: "mail", Name: "Email link"}}
if id, ok := mujica.OAuthButtons(c, providers, mujica.OAuthButtonsOptions{Pending: d.pending, Disabled: d.disabled}).Chosen(); ok {
d.pending = id
log("continue with " + id)
}
if d.pending != "" && tradingAction(c, "Finish sign-in") {
log("sign-in with " + d.pending + " finished")
d.pending = ""
}
}
type accountTwoFactorState struct {
code string
resendAt time.Time
disabled bool
err string
}
func accountTwoFactorDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "469", func() accountTwoFactorState { return accountTwoFactorState{} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
r := mujica.TwoFactorInput(c, &d.code, mujica.TwoFactorInputOptions{ResendAt: d.resendAt, Error: d.err, Disabled: d.disabled})
if r.Completed() {
// The code is checked here and never logged.
d.err = ""
if d.code != "123456" {
d.err = "That code is not right."
}
log(fmt.Sprintf("code complete, accepted %v", d.err == ""))
}
if r.Resend() {
d.resendAt, d.code, d.err = c.Now().Add(30*time.Second), "", ""
log("resend requested")
}
}
type accountProfileState struct {
saved, edit mujica.Profile
saving, disabled bool
}
func accountProfileDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "470", func() accountProfileState {
p := mujica.Profile{Name: "Sakiko Togawa", Bio: "Keyboard, Ave Mujica."}
return accountProfileState{saved: p, edit: p}
})
accountToggles(c, log, []string{"Saving", "Disabled"}, &d.saving, &d.disabled)
r := mujica.ProfileEditor(c, &d.edit, mujica.ProfileEditorOptions{Saved: d.saved, Saving: d.saving, Disabled: d.disabled})
if r.Save() {
d.saved = d.edit
log("saved")
}
if r.Discarded() {
log("discarded")
}
if r.ChangeAvatar() {
log("change picture")
}
}
+175
View File
@@ -0,0 +1,175 @@
package main
// accountCode contains the compiled Example bodies.
var accountCode = map[string]string{
"SettingsLayout": `page := ""
sound := true
pages := []mujica.SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SettingsLayout(c, &page, pages, mujica.SettingsLayoutOptions{}, func(id string) {
mujica.SettingsSection(c, "Notifications", func() {
mujica.SettingsRow(c, "Sound", mujica.SettingsRowOptions{ID: "sound"}, func() *ui.Element {
return mujica.Switch(c, &sound, "", mujica.SwitchOptions{})
})
})
})
}
ui.Render(view, 400, 300, 1)`,
"SettingsSearch": `query, page := "", "general"
entries := []mujica.SettingEntry{{ID: "sound", Page: "general", Label: "Sound", Keywords: []string{"audio"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if e, ok := mujica.SettingsSearch(c, &query, entries, mujica.SettingsSearchOptions{}).Chosen(); ok {
page = e.Page
}
fmt.Println("page", page)
}
ui.Render(view, 400, 300, 1)`,
"ThemeSelector": `mode := mujica.System
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
mujica.ThemeSelector(c, &mode, mujica.ThemeSelectorOptions{})
}
ui.Render(view, 400, 300, 1)`,
"AccentColorPicker": `accent := ui.Hex("#7D2034")
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AccentColorPicker(c, &accent, mujica.AccentColorPickerOptions{})
}
ui.Render(view, 400, 300, 1)`,
"ShortcutRecorder": `palette := mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ShortcutRecorder(c, &palette, mujica.ShortcutRecorderOptions{
Label: "Command palette",
Taken: []mujica.ShortcutBinding{{Action: "Save", Shortcut: mujica.Shortcut{Mods: ui.Cmd, Key: ui.KeyS}}},
})
}
ui.Render(view, 400, 300, 1)`,
"KeyboardShortcutsList": `query := ""
groups := []mujica.ShortcutGroup{{Title: "General", Bindings: []mujica.ShortcutBinding{
{Action: "Command palette", Shortcut: mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.KeyboardShortcutsList(c, &query, groups, mujica.KeyboardShortcutsListOptions{})
}
ui.Render(view, 400, 300, 1)`,
"LoginForm": `var state mujica.LoginFormState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LoginForm(c, &state, mujica.LoginFormOptions{OnSubmit: func(account, password string, remember bool) {
// Submit credentials through the caller-provided auth service.
state.EndSubmit("")
}})
}
ui.Render(view, 400, 300, 1)`,
"OAuthButtons": `providers := []mujica.OAuthProvider{{ID: "sso", Name: "Company SSO"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.OAuthButtons(c, providers, mujica.OAuthButtonsOptions{}).Chosen(); ok {
fmt.Println("start sign-in with", id)
}
}
ui.Render(view, 400, 300, 1)`,
"TwoFactorInput": `code := ""
resendAt := time.Now().Add(30 * time.Second)
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.TwoFactorInput(c, &code, mujica.TwoFactorInputOptions{ResendAt: resendAt})
if r.Completed() {
fmt.Println("verify the code")
}
}
ui.Render(view, 400, 300, 1)`,
"ProfileEditor": `saved := mujica.Profile{Name: "Sakiko", Bio: "Keyboard."}
profile := saved
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ProfileEditor(c, &profile, mujica.ProfileEditorOptions{Saved: saved}).Save() {
saved = profile
}
}
ui.Render(view, 400, 300, 1)`,
"AccountSwitcher": `current := "a"
accounts := []mujica.Account{{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.AccountSwitcher(c, &current, accounts, mujica.AccountSwitcherOptions{}).Add() {
fmt.Println("sign in another account")
}
}
ui.Render(view, 400, 300, 1)`,
"WorkspaceSwitcher": `current := "ave"
spaces := []mujica.Workspace{{ID: "ave", Name: "Ave Mujica"}, {ID: "mygo", Name: "MyGO!!!!!"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WorkspaceSwitcher(c, &current, spaces, mujica.WorkspaceSwitcherOptions{})
}
ui.Render(view, 400, 300, 1)`,
"UserMenu": `status := mujica.PresenceOnline
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.UserMenu(c, &status, mujica.UserMenuOptions{Name: "Sakiko Togawa"}).SignOut() {
fmt.Println("sign out")
}
}
ui.Render(view, 400, 300, 1)`,
"SessionList": `sessions := []mujica.Session{{ID: "s1", Device: "MacBook Pro", Current: true}, {ID: "s2", Device: "iPhone", Location: "Osaka"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if id, ok := mujica.SessionList(c, sessions, mujica.SessionListOptions{}).Revoked(); ok {
fmt.Println("sign out", id)
}
}
ui.Render(view, 400, 300, 1)`,
"ApiKeyManager": `var state mujica.ApiKeyManagerState
keys := []mujica.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if name, ok := mujica.ApiKeyManager(c, &state, keys, mujica.ApiKeyManagerOptions{}).Create(); ok {
fmt.Println("create", name)
state.Reveal("secret from your key service")
}
}
ui.Render(view, 400, 300, 1)`,
"UsageQuota": `items := []mujica.QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.UsageQuota(c, items, mujica.UsageQuotaOptions{ResetAt: time.Now().Add(72 * time.Hour)})
}
ui.Render(view, 400, 300, 1)`,
"OnboardingWizard": `step := 0
steps := []mujica.WizardStep{{Title: "Welcome"}, {Title: "Pick a theme"}, {Title: "Done"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.OnboardingWizard(c, &step, steps, mujica.OnboardingWizardOptions{}).Finished() {
fmt.Println("onboarded")
}
}
ui.Render(view, 400, 300, 1)`,
"Coachmark": `open, step := true, 0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
compose := mujica.Button(c, "Compose", mujica.ButtonOptions{})
mujica.Coachmark(c, &open, &step, []mujica.CoachmarkStep{{Anchor: compose, Title: "Write here", Body: "Start a new letter."}})
}
ui.Render(view, 400, 300, 1)`,
"WhatsNewDialog": `open, hide := true, false
releases := []mujica.Release{{Version: "1.1", Changes: []mujica.ReleaseChange{{Kind: mujica.ChangeNew, Text: "500 components."}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WhatsNewDialog(c, &open, &hide, releases, mujica.WhatsNewDialogOptions{})
}
ui.Render(view, 400, 300, 1)`,
"FeedbackWidget": `var state mujica.FeedbackWidgetState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if f, ok := mujica.FeedbackWidget(c, &state, mujica.FeedbackWidgetOptions{}).Submitted(); ok {
fmt.Println("send", f.Kind)
state.EndSubmit("")
}
}
ui.Render(view, 400, 300, 1)`,
}
+276
View File
@@ -0,0 +1,276 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
type accountSwitchState struct {
account, space string
accounts []mujica.Account
disabled bool
}
func accountSwitcherDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "471", func() accountSwitchState {
return accountSwitchState{account: "a", accounts: []mujica.Account{
{ID: "a", Name: "Sakiko Togawa", Email: "sakiko@court"}, {ID: "b", Name: "Oblivionis", Email: "ob@mask"}}}
})
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
r := mujica.AccountSwitcher(c, &d.account, d.accounts, mujica.AccountSwitcherOptions{Disabled: d.disabled})
if r.Changed() {
log("switched to " + d.account)
}
if r.Add() {
n := len(d.accounts) + 1
d.accounts = append(d.accounts, mujica.Account{ID: fmt.Sprint(n), Name: fmt.Sprintf("Guest %d", n)})
log("add account")
}
}
var accountSpaces = []mujica.Workspace{
{ID: "ave", Name: "Ave Mujica", Detail: "5 members"}, {ID: "mygo", Name: "MyGO!!!!!", Detail: "5 members"},
{ID: "crychic", Name: "CRYCHIC", Detail: "Archived"}, {ID: "sumimi", Name: "sumimi", Detail: "2 members"},
}
func accountWorkspaceDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "472", func() accountSwitchState { return accountSwitchState{space: "ave"} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
if mujica.WorkspaceSwitcher(c, &d.space, accountSpaces, mujica.WorkspaceSwitcherOptions{Disabled: d.disabled}).Changed() {
log("workspace " + d.space)
}
}
type accountUserState struct {
status mujica.UserStatus
disabled bool
}
func accountUserMenuDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "473", func() accountUserState { return accountUserState{} })
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
r := mujica.UserMenu(c, &d.status, mujica.UserMenuOptions{Name: "Sakiko Togawa", Email: "sakiko@court", Disabled: d.disabled})
if r.StatusChanged() {
log(fmt.Sprintf("status %d", d.status))
}
if r.Settings() {
log("open settings")
}
if r.SignOut() {
log("sign out")
}
}
type accountSessionsState struct {
sessions []mujica.Session
disabled bool
}
func accountSessionsDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "474", func() accountSessionsState {
return accountSessionsState{sessions: []mujica.Session{
{ID: "s1", Device: "MacBook Pro · Safari", Location: "Tokyo", LastActive: time.Now(), Current: true},
{ID: "s2", Device: "iPhone · App", Location: "Osaka", LastActive: time.Now().Add(-26 * time.Hour)},
{ID: "s3", Device: "Windows · Edge", Location: "Kyoto"},
}}
})
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
r := mujica.SessionList(c, d.sessions, mujica.SessionListOptions{Disabled: d.disabled})
if id, ok := r.Revoked(); ok {
for i, s := range d.sessions {
if s.ID == id {
d.sessions = append(d.sessions[:i:i], d.sessions[i+1:]...)
break
}
}
log("signed out " + id)
}
if r.OthersRevoked() {
d.sessions = d.sessions[:1]
log("signed out every other session")
}
}
type accountKeysState struct {
state mujica.ApiKeyManagerState
keys []mujica.APIKey
disabled, reject bool
}
func accountKeysDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "475", func() accountKeysState {
return accountKeysState{keys: []mujica.APIKey{{ID: "k1", Name: "Deploy bot", Prefix: "mk_live_9f2c", Created: time.Now().AddDate(0, -1, 0)}}}
})
accountToggles(c, log, []string{"Disabled", "Create fails"}, &d.disabled, &d.reject)
r := mujica.ApiKeyManager(c, &d.state, d.keys, mujica.ApiKeyManagerOptions{Disabled: d.disabled})
if name, ok := r.Create(); ok {
if d.reject {
d.state.Fail("Key limit reached.")
log("create failed")
return
}
// The caller supplies the generated key.
n := len(d.keys) + 1
prefix := fmt.Sprintf("mk_live_%04x", n*7919)
d.keys = append(d.keys, mujica.APIKey{ID: fmt.Sprint("k", n), Name: name, Prefix: prefix, Created: time.Now()})
d.state.Reveal(fmt.Sprintf("%s_%x", prefix, time.Now().UnixNano()))
log("created key " + name)
}
if id, ok := r.Revoked(); ok {
for i, k := range d.keys {
if k.ID == id {
d.keys = append(d.keys[:i:i], d.keys[i+1:]...)
break
}
}
log("revoked " + id)
}
}
type accountQuotaState struct{ calls float64 }
func accountQuotaDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "476", func() accountQuotaState { return accountQuotaState{calls: 4200} })
if mujica.Slider(c, &d.calls, mujica.SliderOptions{Min: 0, Max: 12000, Step: 100, Label: "API calls used", ShowValue: true}).Changed() {
log(fmt.Sprintf("calls %.0f", d.calls))
}
ui.Box(c).Width(440).Children(func() {
mujica.UsageQuota(c, []mujica.QuotaItem{{Label: "API calls", Used: d.calls, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}},
mujica.UsageQuotaOptions{ResetAt: time.Now().Add(52 * time.Hour)})
})
}
type accountWizardState struct {
step int
theme mujica.Mode
name string
noSkip bool
}
func accountWizardDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "477", func() accountWizardState { return accountWizardState{} })
accountToggles(c, log, []string{"Hide Skip"}, &d.noSkip)
steps := []mujica.WizardStep{
{Title: "Welcome", Description: "Three short steps to set up your court."},
{Title: "Your name", Ready: func() bool { return d.name != "" }, Content: func() {
if mujica.TextInput(c, &d.name, mujica.TextInputOptions{Label: "Name", Placeholder: "Name"}).Changed() {
log("name changed")
}
}},
{Title: "Pick a theme", Content: func() {
if mujica.ThemeSelector(c, &d.theme, mujica.ThemeSelectorOptions{}).Changed() {
log("theme " + d.theme.String())
}
}},
}
from := d.step
r := mujica.OnboardingWizard(c, &d.step, steps, mujica.OnboardingWizardOptions{NoSkip: d.noSkip})
if d.step != from {
log(fmt.Sprintf("step %d", d.step+1))
}
if r.Finished() || r.Skipped() {
log(fmt.Sprintf("finished %v skipped %v", r.Finished(), r.Skipped()))
d.step = 0
}
}
type accountCoachState struct {
open, disabled bool
step int
}
func accountCoachDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "478", func() accountCoachState { return accountCoachState{} })
accountToggles(c, log, []string{"Disabled anchor"}, &d.disabled)
if tradingAction(c, "Start tour") {
d.open, d.step = true, 0
log("tour started")
}
var compose, archive *ui.Element
ui.Row(c).Gap(48).Padding(8).Children(func() {
compose = mujica.Button(c, "Compose", mujica.ButtonOptions{Disabled: d.disabled})
archive = mujica.Button(c, "Archive", mujica.ButtonOptions{Variant: mujica.Secondary})
})
if compose.Clicked() {
log("compose")
}
if archive.Clicked() {
log("archive")
}
from := d.step
r := mujica.Coachmark(c, &d.open, &d.step, []mujica.CoachmarkStep{
{Anchor: compose, Title: "Write here", Body: "Start a new letter."},
{Anchor: archive, Title: "Keep it tidy", Body: "Archive threads you have finished."},
})
if d.step != from {
log(fmt.Sprintf("tip %d", d.step+1))
}
if r.Finished() {
log("tour finished")
}
if r.Dismissed() {
log("tour dismissed")
}
}
type accountWhatsNewState struct{ open, hide bool }
func accountWhatsNewDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "479", func() accountWhatsNewState { return accountWhatsNewState{} })
if tradingAction(c, "Show what's new") {
d.open = true
log("open")
}
releases := []mujica.Release{
{Version: "1.1", Date: "2026-10-05", Changes: []mujica.ReleaseChange{{Kind: mujica.ChangeNew, Text: "400 new components."}, {Kind: mujica.ChangeFixed, Text: "Snapshots no longer drift."}}},
{Version: "1.0", Date: "2026-09-01", Changes: []mujica.ReleaseChange{{Kind: mujica.ChangeImproved, Text: "Court-gothic theme."}}},
}
was := d.open
if mujica.WhatsNewDialog(c, &d.open, &d.hide, releases, mujica.WhatsNewDialogOptions{}).Changed() {
log(fmt.Sprintf("don't show again %v", d.hide))
}
if was && !d.open {
log("closed")
}
}
type accountFeedbackState struct {
state mujica.FeedbackWidgetState
disabled bool
}
func accountFeedbackDemo(c *ui.Context, log func(string)) {
d := ui.Local(c.Root(), "480", func() accountFeedbackState { return accountFeedbackState{} })
done := *ui.Local(c.Root(), "480.done", func() chan string { return make(chan string, 1) })
select {
case e := <-done:
d.state.EndSubmit(e)
log("feedback sent")
default:
}
accountToggles(c, log, []string{"Disabled"}, &d.disabled)
before := d.state.Draft
r := mujica.FeedbackWidget(c, &d.state, mujica.FeedbackWidgetOptions{Disabled: d.disabled, Attachments: func() {
if tradingAction(c, "Attach screenshot") {
log("attach screenshot")
}
}})
if before.Kind != d.state.Draft.Kind {
log("type changed")
}
if before.Text != d.state.Draft.Text {
log("message changed")
}
if f, ok := r.Submitted(); ok {
log(fmt.Sprintf("send kind %d, %d characters", f.Kind, len([]rune(f.Text))))
go func() {
time.Sleep(600 * time.Millisecond)
done <- ""
c.Invalidate()
}()
}
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"strings"
"testing"
)
func accountClick(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func TestAccountPagesInteractiveEvents(t *testing.T) {
actions := map[string]func(*testing.T, *ui.Tester){
"461": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Privacy") },
"462": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Search settings")
tt.Type("audio")
tt.Key(0, ui.KeyEnter)
},
"463": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Dark") },
"464": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "#355E80") },
"465": func(t *testing.T, tt *ui.Tester) {
value := mujica.Shortcut{Mods: ui.Cmd | ui.Shift, Key: ui.KeyP}
accountClick(t, tt, "Command palette: "+value.String())
tt.Key(ui.Cmd, ui.KeyK)
},
"466": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Search shortcuts"); tt.Type("Save") },
"467": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Email or username")
tt.Type("test@court")
accountClick(t, tt, "Password")
tt.Type("test-secret-467")
accountClick(t, tt, "Sign in")
},
"468": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Continue with Git host") },
"469": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Verification code"); tt.Type("381942") },
"470": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Name")
tt.Type(" edited")
accountClick(t, tt, "Save")
},
"471": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Sakiko Togawa")
accountClick(t, tt, "Oblivionis · ob@mask")
},
"472": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Ave Mujica")
tt.Type("arch")
tt.Key(0, ui.KeyEnter)
},
"473": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Sakiko Togawa, Online")
accountClick(t, tt, "Away")
},
"474": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Sign out of iPhone · App") },
"475": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Create key")
tt.Type("CI")
tt.Key(0, ui.KeyEnter)
},
"476": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "API calls used"); tt.Key(0, ui.KeyRight) },
"477": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Next") },
"478": func(t *testing.T, tt *ui.Tester) { accountClick(t, tt, "Start tour"); accountClick(t, tt, "Next") },
"479": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Show what's new")
accountClick(t, tt, "Don't show again")
},
"480": func(t *testing.T, tt *ui.Tester) {
accountClick(t, tt, "Message")
tt.Type("This is a feedback test.")
accountClick(t, tt, "Send feedback")
},
}
if len(accountPages) != 20 {
t.Fatalf("%d account pages", len(accountPages))
}
for _, p := range accountPages {
t.Run(p.ID, func(t *testing.T) {
if p.Category != "Settings & Account" || p.Code == "" || p.Keys == "" {
t.Fatal("incomplete gallery metadata")
}
var events []string
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Column(c).Padding(20).Gap(16).Children(func() { p.Demo(c, func(s string) { events = append(events, s) }) })
}, 1100, 800)
actions[p.ID](t, tt)
if len(events) == 0 {
t.Fatal("interaction has no gallery event")
}
logs := strings.Join(events, "\n")
if strings.Contains(logs, "test-secret-467") || strings.Contains(logs, "381942") {
t.Fatal("gallery event exposes a secret")
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
)
// FeedbackKind is what the feedback is about.
type FeedbackKind int
const (
FeedbackBug FeedbackKind = iota
FeedbackIdea
FeedbackPraise
FeedbackOther
)
// Feedback is what a FeedbackWidget sends.
type Feedback struct {
Kind FeedbackKind
Text string
}
// FeedbackWidgetState holds the draft and its form state.
type FeedbackWidgetState struct {
Draft Feedback
Form FormState
}
// EndSubmit clears successful drafts and preserves failed ones.
func (s *FeedbackWidgetState) EndSubmit(err string) {
s.Form.EndSubmit(err)
if err == "" {
s.Draft.Text = ""
}
}
// FeedbackWidgetOptions configure a FeedbackWidget.
type FeedbackWidgetOptions struct {
// Attachments builds the caller-provided screenshot or file slot.
Attachments func()
// MinLength is the fewest characters accepted; zero means 10.
MinLength int
Disabled bool
}
// FeedbackWidgetResult carries the submitted feedback.
type FeedbackWidgetResult struct {
Element *ui.Element
feedback Feedback
submitted bool
}
// Submitted returns feedback; finish the request with EndSubmit.
func (r FeedbackWidgetResult) Submitted() (Feedback, bool) { return r.feedback, r.submitted }
// FeedbackWidget validates feedback and submits it once.
func FeedbackWidget(c *ui.Context, s *FeedbackWidgetState, opts FeedbackWidgetOptions) FeedbackWidgetResult {
if opts.MinLength < 0 {
panic("mujica: FeedbackWidget MinLength cannot be negative")
}
if s.Draft.Kind < FeedbackBug || s.Draft.Kind > FeedbackOther || opts.MinLength > 2000 {
panic("mujica: FeedbackWidget invalid kind or length")
}
minLen := opts.MinLength
if minLen == 0 {
minLen = 10
}
d := 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 {
copy := *field
frozen.Form.fields[name] = &copy
}
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
for f := FeedbackBug; f <= FeedbackOther; f++ {
segs = append(segs, Segment{Label: msg(c, fmt.Sprintf("feedback_widget.kind%d", f))})
}
SegmentedControl(c, &kind, segs, SegmentedControlOptions{Equal: true, Label: 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,
Validate: func(v string) string {
if n := len([]rune(strings.TrimSpace(v))); n < minLen {
return fmt.Sprintf(msg(c, "feedback_widget.short"), minLen)
}
return ""
}}, func() *ui.Element {
return TextArea(c, &s.Draft.Text, TextAreaOptions{MinHeight: 96, MaxLength: 2000, Disabled: locked}).Input
})
if opts.Attachments != nil {
ui.Column(c).Gap(d.Unit()).Disabled(locked).Children(opts.Attachments)
}
})
if res.Submitted() && !disabled {
r.feedback, r.submitted = s.Draft, true
slog.Debug("mujica: feedback_widget submitted", "kind", s.Draft.Kind)
}
})
return r
}
+121
View File
@@ -0,0 +1,121 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestFeedbackWidgetValidatesSubmitsOnce(t *testing.T) {
var s FeedbackWidgetState
var sent []Feedback
attached := false
tt := accountTester(500, 500, func(c *ui.Context) {
r := FeedbackWidget(c, &s, FeedbackWidgetOptions{Attachments: func() { attached = true; ui.Text(c, "screenshot slot") }})
if f, ok := r.Submitted(); ok {
sent = append(sent, f)
}
})
if !attached || !tt.HasText("screenshot slot") {
t.Fatal("attachment slot not built")
}
_ = tt.Click("Idea")
_ = tt.Click("Message")
tt.Type("short")
_ = tt.Click("Send feedback")
if len(sent) != 0 || !tt.HasText("Write at least 10 characters.") {
t.Fatalf("short sent: %q", tt.Texts())
}
_ = tt.Click("Message")
tt.Type(" but now long enough")
_ = tt.Click("Send feedback")
_ = tt.Click("Send feedback")
if len(sent) != 1 || sent[0].Kind != FeedbackIdea || sent[0].Text != "short but now long enough" {
t.Fatalf("sent %+v", sent)
}
s.EndSubmit("")
tt.Frame()
if s.Draft.Text != "" {
t.Fatal("sent draft kept")
}
}
func TestFeedbackWidgetDisabled(t *testing.T) {
s := FeedbackWidgetState{Draft: Feedback{Text: "A long enough message."}}
sent := 0
tt := accountTester(500, 500, func(c *ui.Context) {
if _, ok := FeedbackWidget(c, &s, FeedbackWidgetOptions{Disabled: true}).Submitted(); ok {
sent++
}
})
_ = tt.Click("Bug")
_ = tt.Click("Praise")
_ = tt.Click("Send feedback")
if sent != 0 || s.Draft.Kind != FeedbackBug {
t.Fatal("disabled widget acted")
}
}
func TestFeedbackWidgetSnapshot(t *testing.T) {
snapshot(t, "feedback_widget", 460, 400, func(c *ui.Context) {
s := ui.Local(c.Root(), "s", func() FeedbackWidgetState {
return FeedbackWidgetState{Draft: Feedback{Kind: FeedbackBug, Text: "The ticker stutters on resize."}}
})
ui.Column(c).Padding(16).Children(func() {
FeedbackWidget(c, s, FeedbackWidgetOptions{Attachments: func() {
Button(c, "Attach screenshot", ButtonOptions{Variant: Secondary})
}})
})
})
}
func TestFeedbackWidgetPendingFreezesDraftAndAttachments(t *testing.T) {
s := FeedbackWidgetState{Draft: Feedback{Kind: FeedbackIdea, Text: "A long enough message."}}
attachments := 0
tt := accountTester(500, 500, func(c *ui.Context) {
FeedbackWidget(c, &s, FeedbackWidgetOptions{Attachments: func() {
if Button(c, "Attach", ButtonOptions{}).Clicked() {
attachments++
}
}})
})
if err := tt.Click("Send feedback"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Praise"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Message"); err != nil {
t.Fatal(err)
}
tt.Type("modified")
if err := tt.Click("Attach"); err != nil {
t.Fatal(err)
}
if s.Draft.Kind != FeedbackIdea || s.Draft.Text != "A long enough message." || attachments != 0 {
t.Fatal("pending feedback changes its draft or attachments")
}
}
func TestFeedbackWidgetFailurePreservesDraftAndInvalidLength(t *testing.T) {
s := FeedbackWidgetState{Draft: Feedback{Kind: FeedbackIdea, Text: "A long enough message."}}
tt := accountTester(600, 500, func(c *ui.Context) { FeedbackWidget(c, &s, FeedbackWidgetOptions{}) })
if err := tt.Click("Send feedback"); err != nil {
t.Fatal(err)
}
s.EndSubmit("Send failed.")
tt.Frame()
if s.Draft.Text != "A long enough message." || !tt.HasText("Send failed.") {
t.Fatal("failed feedback loses its draft or message")
}
accountPanics(t, func(c *ui.Context) { FeedbackWidget(c, &s, FeedbackWidgetOptions{MinLength: 2001}) })
}
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 {
t.Fatal("disabled frame edits form state")
}
}
+109
View File
@@ -0,0 +1,109 @@
package mujica
import (
"reflect"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// ShortcutGroup is a titled group of bindings in a KeyboardShortcutsList.
type ShortcutGroup struct {
Title string
Bindings []ShortcutBinding
}
// KeyboardShortcutsListOptions configure a KeyboardShortcutsList.
type KeyboardShortcutsListOptions struct {
// Label names the search field; empty uses the built-in one.
Label string
}
type shortcutsListRow struct{ group, binding int }
type shortcutsListState struct {
groups []ShortcutGroup
query string
rows []shortcutsListRow
ready bool
}
// 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)
label := opts.Label
if label == "" {
label = 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})
shortcutsListPlan(st, groups, strings.ToLower(strings.TrimSpace(*query)))
if len(st.rows) == 0 {
EmptyState(c, EmptyStateOptions{Kind: EmptyNoResults, Query: *query})
return
}
accountRows(c, len(st.rows), func(i int) any { return st.rows[i] }, func(i int) {
r := st.rows[i]
g := groups[r.group]
if r.binding < 0 {
ui.Text(c, g.Title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Padding(d.Unit()*2, 0).Role(ui.RoleGroup).Label(g.Title)
return
}
b := g.Bindings[r.binding]
ui.Row(c).MinHeight(d.ControlHeight()).AlignItems(ui.Center).Gap(d.Unit()*4).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Role(ui.RoleRow).
Label(b.Action + ", " + b.Shortcut.String()).Children(func() {
ui.Text(c, b.Action).Grow(1).MinWidth(0).Role(ui.RoleNone)
shortcutKbd(c, b.Shortcut)
})
})
})
return e
}
func shortcutsListPlan(st *shortcutsListState, groups []ShortcutGroup, q string) {
if st.ready && st.query == q && reflect.DeepEqual(st.groups, groups) {
return
}
st.ready, st.query = true, q
st.groups = slices.Clone(groups)
st.rows = st.rows[:0]
for gi, g := range groups {
st.groups[gi].Bindings = slices.Clone(g.Bindings)
header := false
for bi, b := range g.Bindings {
if b.Action == "" {
panic("mujica: KeyboardShortcutsList binding needs an Action")
}
if q != "" && !strings.Contains(strings.ToLower(b.Action), q) && !strings.Contains(strings.ToLower(b.Shortcut.String()), q) {
continue
}
if !header && g.Title != "" {
st.rows = append(st.rows, shortcutsListRow{gi, -1})
header = true
}
st.rows = append(st.rows, shortcutsListRow{gi, bi})
}
}
}
func accountRows(c *ui.Context, n int, key func(int) any, row func(int)) *ui.Element {
if n <= 8 {
return ui.Column(c).Children(func() {
for i := range n {
ui.Column(c).Key(key(i)).Children(func() { row(i) })
}
})
}
site := ui.Column(c).MinHeight(0).Shrink(1)
st := ui.Local(site, "rows", func() ui.ListState { return ui.ListState{} })
st.Key = key
site.Children(func() { ui.List(c, st, n, row).Height(320).MinHeight(0).Shrink(1) })
return site
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var shortcutGroups = []ShortcutGroup{
{Title: "General", Bindings: []ShortcutBinding{{"Command palette", Shortcut{ui.Ctrl | ui.Shift, ui.KeyP}}, {"Settings", Shortcut{ui.Ctrl, ui.KeyComma}}}},
{Title: "Editing", Bindings: []ShortcutBinding{{"Duplicate line", Shortcut{ui.Alt | ui.Shift, ui.KeyDown}}, {"Toggle comment", Shortcut{}}}},
}
func TestKeyboardShortcutsListGroupsAndSearch(t *testing.T) {
query := ""
tt := accountTester(500, 500, func(c *ui.Context) { KeyboardShortcutsList(c, &query, shortcutGroups, KeyboardShortcutsListOptions{}) })
for _, want := range []string{"General", "Editing", "Command palette", "Toggle comment", "—"} {
if !tt.HasText(want) {
t.Fatalf("missing %q in %q", want, tt.Texts())
}
}
if _, ok := tt.Find("Duplicate line, alt+shift+down"); !ok {
t.Fatal("row not spoken with its keys")
}
_ = tt.Click("Search shortcuts")
tt.Type("comment")
if tt.HasText("General") || !tt.HasText("Toggle comment") {
t.Fatalf("search: %q", tt.Texts())
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("ctrl+,")
if !tt.HasText("Settings") || tt.HasText("Editing") {
t.Fatalf("key search: %q", tt.Texts())
}
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("zzz")
if !tt.HasText("No results") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestKeyboardShortcutsListSnapshot(t *testing.T) {
snapshot(t, "keyboard_shortcuts_list", 460, 320, func(c *ui.Context) {
q := ui.Local(c.Root(), "q", func() string { return "" })
ui.Column(c).Padding(16).Children(func() { KeyboardShortcutsList(c, q, shortcutGroups, KeyboardShortcutsListOptions{}) })
})
}
func TestKeyboardShortcutsListLargeDataBuildsVisibleRows(t *testing.T) {
items := make([]ShortcutBinding, 100000)
for i := range items {
items[i] = ShortcutBinding{Action: fmt.Sprintf("Action %d", i)}
}
query := ""
tt := accountTester(500, 500, func(c *ui.Context) {
KeyboardShortcutsList(c, &query, []ShortcutGroup{{Title: "General", Bindings: items}}, KeyboardShortcutsListOptions{})
})
if n := len(tt.Texts()); n > 100 {
t.Fatalf("built %d text elements for a bounded viewport", n)
}
if err := tt.Click("Search shortcuts"); err != nil {
t.Fatal(err)
}
tt.Type("99999")
if !tt.HasText("Action 99999") || tt.HasText("Action 99998") {
t.Fatal("large-list search does not reach last binding")
}
}
func TestKeyboardShortcutsListDisabledSearchAndInvalidBinding(t *testing.T) {
q := ""
tt := accountTester(600, 500, func(c *ui.Context) {
ui.Column(c).Disabled(true).Children(func() { KeyboardShortcutsList(c, &q, shortcutGroups, KeyboardShortcutsListOptions{}) })
})
if err := tt.Click("Search shortcuts"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Type("save")
if q != "" {
t.Fatal("disabled search edits")
}
accountPanics(t, func(c *ui.Context) {
KeyboardShortcutsList(c, &q, []ShortcutGroup{{Bindings: []ShortcutBinding{{}}}}, KeyboardShortcutsListOptions{})
})
}
func TestKeyboardShortcutsListCachedPlanSeesInPlaceChanges(t *testing.T) {
st := shortcutsListState{}
groups := []ShortcutGroup{{Title: "General", Bindings: []ShortcutBinding{{Action: "Save"}}}}
shortcutsListPlan(&st, groups, "save")
if len(st.rows) != 2 {
t.Fatal("missing header or binding")
}
before := &st.rows[0]
shortcutsListPlan(&st, groups, "save")
if before != &st.rows[0] {
t.Fatal("unchanged data rebuilt shortcut plan")
}
groups[0].Bindings[0].Action = "Open"
shortcutsListPlan(&st, groups, "save")
if len(st.rows) != 0 {
t.Fatal("cached list misses in-place binding change")
}
}
+99
View File
@@ -0,0 +1,99 @@
package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// LoginFormState holds the form; only OnSubmit sees the password.
type LoginFormState struct {
Account string
Remember bool
password string
tried bool
submitting bool
err string
}
// EndSubmit finishes a sign-in, shows err and wipes the password.
func (s *LoginFormState) EndSubmit(err string) {
if !s.submitting {
panic("mujica: LoginFormState.EndSubmit without a submit in progress")
}
s.submitting, s.err, s.password = false, err, ""
slog.Debug("mujica: login_form submit ended", "failed", err != "")
}
// LoginFormOptions configure a LoginForm. OnSubmit is required.
type LoginFormOptions struct {
// OnSubmit alone receives the credentials; then call EndSubmit.
OnSubmit func(account, password string, remember bool)
// AccountLabel replaces the built-in "Email or username".
AccountLabel string
Disabled bool
}
// LoginForm collects credentials; it never checks or stores them.
func LoginForm(c *ui.Context, s *LoginFormState, opts LoginFormOptions) *ui.Element {
if opts.OnSubmit == nil {
panic("mujica: LoginForm needs OnSubmit")
}
k := Tokens(c)
d := Density(c)
accountLabel := opts.AccountLabel
if accountLabel == "" {
accountLabel = 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 ""
}
submit := false
var acc, pw 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)})
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)})
return pw.Input
})
if acc.Changed() || pw.Changed() {
s.err = ""
}
Checkbox(c, &s.Remember, msg(c, "login_form.remember"), 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.FillWidth()
submit = b.Clicked() || acc.Submitted() || pw.Submitted()
})
if submit && !disabled && !s.submitting {
s.tried = true
switch {
case strings.TrimSpace(s.Account) == "":
acc.Input.Focus()
return e
case s.password == "":
pw.Input.Focus()
return e
}
s.submitting, s.err = true, ""
slog.Debug("mujica: login_form submitted")
opts.OnSubmit(strings.TrimSpace(s.Account), s.password, s.Remember)
}
return e
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
// accountLogs records every debug log until the test ends.
func accountLogs(t *testing.T) *bytes.Buffer {
t.Helper()
var b bytes.Buffer
saved := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&b, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(saved) })
return &b
}
type loginCall struct {
account, password string
remember bool
}
func loginTester(s *LoginFormState, calls *[]loginCall, disabled *bool) *ui.Tester {
return accountTester(420, 400, func(c *ui.Context) {
LoginForm(c, s, LoginFormOptions{Disabled: *disabled, OnSubmit: func(a, p string, r bool) {
*calls = append(*calls, loginCall{a, p, r})
}})
})
}
func TestLoginFormSubmitsOnceThroughCallback(t *testing.T) {
logs := accountLogs(t)
const secret = "s3cret-Sakiko"
var s LoginFormState
var calls []loginCall
disabled := false
tt := loginTester(&s, &calls, &disabled)
_ = tt.Click("Email or username")
tt.Type(" sakiko@court ")
_ = tt.Click("Password")
tt.Type(secret)
_ = tt.Click("Remember me")
tt.Key(ui.Shift, ui.KeyTab)
tt.Key(ui.Shift, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Sign in")
if len(calls) != 1 || calls[0].account != "sakiko@court" || calls[0].password != secret || !calls[0].remember {
t.Fatalf("%d calls", len(calls))
}
if strings.Contains(logs.String(), secret) {
t.Fatal("the password reached the log")
}
s.EndSubmit("Too many attempts. Try again in 5 minutes.")
tt.Frame()
if !tt.HasText("Too many attempts. Try again in 5 minutes.") || s.password != "" || s.Account != " sakiko@court " {
t.Fatalf("shown %v wiped %v account %q", tt.HasText("Too many attempts. Try again in 5 minutes."), s.password == "", s.Account)
}
}
func TestLoginFormRequiresBothAndBlocksDisabled(t *testing.T) {
var s LoginFormState
var calls []loginCall
disabled := false
tt := loginTester(&s, &calls, &disabled)
_ = tt.Click("Sign in")
if len(calls) != 0 || !tt.HasText("Required") || !tt.Focused("Email or username") {
t.Fatalf("empty submit: %d calls", len(calls))
}
tt.Type("uika")
tt.Key(0, ui.KeyEnter)
if len(calls) != 0 || !tt.Focused("Password") {
t.Fatal("submitted without a password")
}
disabled = true
tt.Type("pw")
_ = tt.Click("Sign in")
if len(calls) != 0 || s.password != "" {
t.Fatal("disabled form took input or submitted")
}
}
func TestLoginFormSnapshot(t *testing.T) {
snapshot(t, "login_form", 400, 330, func(c *ui.Context) {
s := ui.Local(c.Root(), "s", func() LoginFormState {
return LoginFormState{Account: "sakiko@court", err: "Wrong account or password."}
})
LoginForm(c, s, LoginFormOptions{OnSubmit: func(string, string, bool) {}})
})
}
+123
View File
@@ -0,0 +1,123 @@
package mujica
func init() {
addMessages(map[string][2]string{
"settings_layout.title": {"Settings", "设置"},
"settings_search.placeholder": {"Search settings", "搜索设置"},
"settings_search.none": {"No settings match", "没有匹配的设置"},
"theme_selector.label": {"Appearance", "外观"},
"theme_selector.System": {"System", "跟随系统"},
"theme_selector.Light": {"Light", "浅色"},
"theme_selector.Dark": {"Dark", "深色"},
"accent_color_picker.label": {"Accent color", "主色"},
"accent_color_picker.low": {"Low contrast", "对比度不足"},
"accent_color_picker.low_note": {"Marked colors are too faint for text on them.", "带标记的颜色对比度不足,不宜承载文字。"},
"accent_color_picker.chosen_low": {"%s has low contrast; text on it may be hard to read.", "%s 对比度不足,其上的文字可能难以阅读。"},
"shortcut_recorder.press": {"Press keys…", "请按下快捷键…"},
"shortcut_recorder.none": {"none", "未设置"},
"shortcut_recorder.conflict": {"%s is already used by %s", "%s 已被“%s”使用"},
"shortcut_recorder.needs_modifier": {"Add Ctrl, Alt or Command; F-keys may stand alone.", "请加上 Ctrl、Alt 或 Command;功能键可单独使用。"},
"keyboard_shortcuts_list.search": {"Search shortcuts", "搜索快捷键"},
"login_form.account": {"Email or username", "邮箱或用户名"},
"login_form.password": {"Password", "密码"},
"login_form.remember": {"Remember me", "记住我"},
"login_form.submit": {"Sign in", "登录"},
"oauth_buttons.continue": {"Continue with %s", "使用 %s 继续"},
"two_factor_input.label": {"Verification code", "验证码"},
"two_factor_input.resend": {"Resend code", "重新发送"},
"two_factor_input.resend_in": {"Resend in %d:%02d", "%d:%02d 后可重新发送"},
"profile_editor.avatar": {"Change picture", "更换头像"},
"profile_editor.name": {"Name", "名称"},
"profile_editor.bio": {"Bio", "简介"},
"profile_editor.unsaved": {"Unsaved changes", "有未保存的更改"},
"profile_editor.discard": {"Discard", "放弃更改"},
"profile_editor.save": {"Save", "保存"},
"account_switcher.accounts": {"Accounts", "账户"},
"account_switcher.add": {"Add account…", "添加账户…"},
"workspace_switcher.label": {"Workspaces", "工作区"},
"workspace_switcher.search": {"Find a workspace", "查找工作区"},
"workspace_switcher.none": {"No workspace matches", "没有匹配的工作区"},
"user_menu.label": {"%s, %s", "%s,%s"},
"user_menu.status0": {"Online", "在线"},
"user_menu.status1": {"Away", "离开"},
"user_menu.status2": {"Do not disturb", "勿扰"},
"user_menu.status3": {"Invisible", "隐身"},
"user_menu.settings": {"Settings", "设置"},
"user_menu.sign_out": {"Sign out", "退出登录"},
"session_list.label": {"Signed-in devices", "已登录设备"},
"session_list.current": {"This device", "本设备"},
"session_list.sign_out": {"Sign out", "注销"},
"session_list.sign_out_of": {"Sign out of %s", "注销 %s"},
"session_list.sign_out_others": {"Sign out of other sessions", "注销其他会话"},
"session_list.confirm_title": {"Sign out everywhere else?", "注销其他所有会话?"},
"session_list.confirm_text": {"%d other sessions will need to sign in again.", "其他 %d 个会话需要重新登录。"},
"session_list.now": {"Active now", "当前活跃"},
"session_list.unknown": {"Last active unknown", "最近活跃时间未知"},
"api_key_manager.create": {"Create key", "创建密钥"},
"api_key_manager.create_title": {"New API key", "新建 API 密钥"},
"api_key_manager.secret_title": {"Copy your new key", "复制新密钥"},
"api_key_manager.name": {"Key name", "密钥名称"},
"api_key_manager.cancel": {"Cancel", "取消"},
"api_key_manager.done": {"I've saved it", "我已保存"},
"api_key_manager.once": {"This key shows only once. Store it somewhere safe now.", "此密钥只显示一次,请立即妥善保存。"},
"api_key_manager.copy": {"Copy key", "复制密钥"},
"api_key_manager.copied": {"Key copied", "密钥已复制"},
"api_key_manager.empty": {"No API keys", "暂无 API 密钥"},
"api_key_manager.revoke": {"Revoke", "撤销"},
"api_key_manager.revoke_named": {"Revoke %s", "撤销 %s"},
"api_key_manager.revoke_title": {"Revoke %s?", "撤销 %s?"},
"api_key_manager.revoke_text": {"Apps using this key stop working at once.", "使用此密钥的应用将立即失效。"},
"api_key_manager.dates": {"Created %s · last used %s", "创建于 %s · 最近使用 %s"},
"api_key_manager.never": {"never", "从未"},
"usage_quota.amount": {"%s of %s %s", "%s / %s %s"},
"usage_quota.near": {"Near limit", "接近上限"},
"usage_quota.over": {"Limit reached", "已达上限"},
"usage_quota.reset_due": {"Resetting now", "正在重置"},
"usage_quota.reset_hours": {"Resets in %d h", "%d 小时后重置"},
"usage_quota.reset_on": {"Resets on %s", "%s 重置"},
"onboarding_wizard.progress": {"Setup progress", "引导进度"},
"onboarding_wizard.count": {"Step %d of %d", "第 %d 步,共 %d 步"},
"onboarding_wizard.skip": {"Skip", "跳过"},
"onboarding_wizard.back": {"Back", "上一步"},
"onboarding_wizard.next": {"Next", "下一步"},
"onboarding_wizard.finish": {"Finish", "完成"},
"coachmark.count": {"%d of %d", "%d / %d"},
"coachmark.skip": {"Skip tour", "跳过引导"},
"coachmark.back": {"Back", "上一步"},
"coachmark.next": {"Next", "下一步"},
"coachmark.done": {"Done", "完成"},
"whats_new_dialog.title": {"What's new", "更新内容"},
"whats_new_dialog.hide": {"Don't show again", "不再显示"},
"whats_new_dialog.close": {"Got it", "知道了"},
"whats_new_dialog.kind0": {"New", "新增"},
"whats_new_dialog.kind1": {"Improved", "改进"},
"whats_new_dialog.kind2": {"Fixed", "修复"},
"feedback_widget.kind": {"Feedback type", "反馈类型"},
"feedback_widget.kind0": {"Bug", "问题"},
"feedback_widget.kind1": {"Idea", "建议"},
"feedback_widget.kind2": {"Praise", "称赞"},
"feedback_widget.kind3": {"Other", "其他"},
"feedback_widget.message": {"Message", "内容"},
"feedback_widget.short": {"Write at least %d characters.", "请至少写 %d 个字。"},
"feedback_widget.send": {"Send feedback", "发送反馈"},
"feedback_widget.clear": {"Clear", "清空"},
})
}
+58
View File
@@ -0,0 +1,58 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
)
// OAuthProvider is a sign-in provider; icons are plain, not trademarks.
type OAuthProvider struct {
ID, Name string
Icon *ui.SVG
}
// OAuthButtonsOptions configure OAuthButtons.
type OAuthButtonsOptions struct {
// Pending is the provider signing in; the others are blocked.
Pending string
Disabled bool
}
// OAuthButtonsResult carries the selected provider.
type OAuthButtonsResult struct {
Element *ui.Element
chosen string
}
// Chosen returns the ID of the provider the user picked.
func (r OAuthButtonsResult) Chosen() (string, bool) { return r.chosen, r.chosen != "" }
// OAuthButtons offers a button per provider; the caller signs in.
func OAuthButtons(c *ui.Context, providers []OAuthProvider, opts OAuthButtonsOptions) OAuthButtonsResult {
if len(providers) == 0 {
panic("mujica: OAuthButtons needs providers")
}
d := Density(c)
var r OAuthButtonsResult
r.Element = ui.Column(c).Gap(d.Unit() * 2).Width(340).Disabled(opts.Disabled)
disabled := r.Element.IsDisabled()
r.Element.Children(func() {
for _, p := range providers {
if p.ID == "" || p.Name == "" {
panic("mujica: OAuthProvider needs an ID and a Name")
}
label := fmt.Sprintf(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,
Disabled: opts.Pending != "" && opts.Pending != p.ID})
if b.Clicked() && !disabled && opts.Pending == "" {
r.chosen = p.ID
slog.Debug("mujica: oauth_buttons chosen", "provider", p.ID)
}
})
}
})
return r
}
+62
View File
@@ -0,0 +1,62 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var oauthProviders = []OAuthProvider{{ID: "gh", Name: "GitHub"}, {ID: "sso", Name: "Company SSO"}}
func TestOAuthButtonsChooseAndPending(t *testing.T) {
var chosen []string
opts := OAuthButtonsOptions{}
tt := accountTester(420, 200, func(c *ui.Context) {
if id, ok := OAuthButtons(c, oauthProviders, opts).Chosen(); ok {
chosen = append(chosen, id)
}
})
if err := tt.Click("Continue with Company SSO"); err != nil {
t.Fatal(err)
}
opts.Pending = "sso"
tt.Frame()
_ = tt.Click("Continue with GitHub")
_ = tt.Click("Continue with Company SSO")
if len(chosen) != 1 || chosen[0] != "sso" {
t.Fatalf("chosen %v", chosen)
}
opts = OAuthButtonsOptions{Disabled: true}
tt.Frame()
_ = tt.Click("Continue with GitHub")
if len(chosen) != 1 {
t.Fatal("disabled buttons chose")
}
defer func() {
if recover() == nil {
t.Fatal("nameless provider accepted")
}
}()
accountTester(100, 100, func(c *ui.Context) { OAuthButtons(c, []OAuthProvider{{ID: "x"}}, OAuthButtonsOptions{}) })
}
func TestOAuthButtonsSnapshot(t *testing.T) {
snapshot(t, "oauth_buttons", 380, 130, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { OAuthButtons(c, oauthProviders, OAuthButtonsOptions{Pending: "gh"}) })
})
}
func TestOAuthButtonsKeyboard(t *testing.T) {
chosen := ""
tt := accountTester(600, 300, func(c *ui.Context) {
if id, ok := OAuthButtons(c, oauthProviders, OAuthButtonsOptions{}).Chosen(); ok {
chosen = id
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if chosen != "sso" {
t.Fatalf("keyboard provider %q", chosen)
}
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// WizardStep is one page of an OnboardingWizard.
type WizardStep struct {
Title, Description string
Content func()
// Ready, when set, must return true before Next is allowed.
Ready func() bool
}
// OnboardingWizardOptions configure an OnboardingWizard.
type OnboardingWizardOptions struct {
// NoSkip hides the Skip action.
NoSkip bool
}
// OnboardingWizardResult reports completion or skipping.
type OnboardingWizardResult struct {
Element *ui.Element
finished, skipped bool
}
// Finished reports Finish on the last step.
func (r OnboardingWizardResult) Finished() bool { return r.finished }
// Skipped reports that the user skipped the rest.
func (r OnboardingWizardResult) Skipped() bool { return r.skipped }
// OnboardingWizard provides progress and step navigation.
func OnboardingWizard(c *ui.Context, step *int, steps []WizardStep, opts OnboardingWizardOptions) OnboardingWizardResult {
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)
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))
for i, s := range steps {
if s.Title == "" {
panic("mujica: WizardStep needs a Title")
}
head[i] = Step{Title: s.Title}
}
at := *step
Steps(c, &at, head, StepsOptions{Label: 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)
if cur.Description != "" {
ui.Text(c, cur.Description).TextColor(k.TextMuted)
}
if cur.Content != nil {
cur.Content()
}
})
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() {
r.skipped = true
}
ui.Box(c).Grow(1)
if *step > 0 && Button(c, msg(c, "onboarding_wizard.back"), ButtonOptions{Variant: Secondary}).Clicked() {
*step--
}
next := msg(c, "onboarding_wizard.next")
if last {
next = msg(c, "onboarding_wizard.finish")
}
if Button(c, next, ButtonOptions{Disabled: !ready}).AutoFocus().Clicked() && ready {
if last {
r.finished = true
} else {
*step++
}
}
})
})
if *step != from || r.finished || r.skipped {
slog.Debug("mujica: onboarding_wizard step", "step", *step, "finished", r.finished, "skipped", r.skipped)
}
return r
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestOnboardingWizardStepsSkipFinish(t *testing.T) {
step, name := 0, ""
var finished, skipped int
steps := []WizardStep{
{Title: "Welcome", Description: "A short tour."},
{Title: "Your name", Ready: func() bool { return name != "" }, Content: func() {}},
{Title: "Done"},
}
tt := accountTester(600, 500, func(c *ui.Context) {
steps[1].Content = func() { TextInput(c, &name, TextInputOptions{Label: "Name"}) }
r := OnboardingWizard(c, &step, steps, OnboardingWizardOptions{})
if r.Finished() {
finished++
}
if r.Skipped() {
skipped++
}
})
if !tt.HasText("Step 1 of 3") || !tt.Focused("Next") {
t.Fatalf("first step: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Next")
if step != 1 {
t.Fatalf("not ready but moved: %d", step)
}
_ = tt.Click("Name")
tt.Type("Sakiko")
_ = tt.Click("Next")
_ = tt.Click("Back")
_ = tt.Click("Next")
if step != 2 || tt.HasText("Skip") {
t.Fatalf("step %d", step)
}
_ = tt.Click("Finish")
if finished != 1 || skipped != 0 {
t.Fatalf("finished %d", finished)
}
step = 0
tt.Frame()
_ = tt.Click("Skip")
if skipped != 1 {
t.Fatal("skip not reported")
}
}
func TestOnboardingWizardSnapshot(t *testing.T) {
snapshot(t, "onboarding_wizard", 580, 330, func(c *ui.Context) {
step := ui.Local(c.Root(), "step", func() int { return 1 })
ui.Column(c).Padding(16).Children(func() {
OnboardingWizard(c, step, []WizardStep{{Title: "Welcome"}, {Title: "Pick a theme", Description: "You can change it later in Settings."}, {Title: "Done"}}, OnboardingWizardOptions{})
})
})
}
func TestOnboardingWizardDisabledAndSingleStep(t *testing.T) {
step, done := 0, 0
disabled := true
tt := accountTester(600, 500, func(c *ui.Context) {
ui.Column(c).Disabled(disabled).Children(func() {
if OnboardingWizard(c, &step, []WizardStep{{Title: "Only step"}}, OnboardingWizardOptions{NoSkip: true}).Finished() {
done++
}
})
})
if err := tt.Click("Finish"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if done != 0 || step != 0 || tt.HasText("Skip") || tt.HasText("Back") {
t.Fatal("disabled single step acts")
}
disabled = false
tt.Frame()
if err := tt.Click("Finish"); err != nil {
t.Fatal(err)
}
if done != 1 {
t.Fatal("single step cannot finish")
}
step = 1
accountPanics(t, func(c *ui.Context) {
OnboardingWizard(c, &step, []WizardStep{{Title: "Only step"}}, OnboardingWizardOptions{})
})
}
+2 -1
View File
@@ -98,7 +98,7 @@ func TestOverlayEscapeClosesTopNonModalLayerFirst(t *testing.T) {
bottom, top := false, false
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Show", ButtonOptions{}).Clicked() {
if Button(c, "Show", ButtonOptions{}).Absolute().Right(10).Top(10).Clicked() {
if bottom {
top = true
}
@@ -107,6 +107,7 @@ func TestOverlayEscapeClosesTopNonModalLayerFirst(t *testing.T) {
Overlay(c, &top, OverlayOptions{Place: OverlayTopRight}, func() { ui.Text(c, "Top layer") })
Drawer(c, &bottom, DrawerOptions{Title: "Bottom layer", NonModal: true, Side: DrawerLeft}, nil)
}, 600, 400)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
_ = tt.Click("Show")
_ = tt.Click("Show") // the overlay opens after the drawer, though built before it
if !bottom || !top {
+103
View File
@@ -0,0 +1,103 @@
package mujica
import (
"log/slog"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// Profile is what a ProfileEditor edits.
type Profile struct {
Name, Bio string
}
// ProfileEditorOptions configure a ProfileEditor.
type ProfileEditorOptions struct {
// Saved is the baseline for detecting unsaved edits.
Saved Profile
// Avatar is the current picture; nil shows initials.
Avatar *ui.Bitmap
// BioLimit bounds the saved biography; zero means 160.
BioLimit int
// Saving shows the save in progress and blocks edits.
Saving bool
Disabled bool
}
// ProfileEditorResult reports save, avatar and discard requests.
type ProfileEditorResult struct {
Element *ui.Element
save, avatar, reset bool
}
// Save reports a request to store the edited profile.
func (r ProfileEditorResult) Save() bool { return r.save }
// ChangeAvatar reports a request to pick a new picture.
func (r ProfileEditorResult) ChangeAvatar() bool { return r.avatar }
// Discarded reports that the edits went back to Saved.
func (r ProfileEditorResult) Discarded() bool { return r.reset }
// ProfileEditor edits name and biography, marking unsaved changes.
func ProfileEditor(c *ui.Context, p *Profile, opts ProfileEditorOptions) ProfileEditorResult {
if opts.BioLimit < 0 {
panic("mujica: ProfileEditor BioLimit cannot be negative")
}
limit := opts.BioLimit
if limit == 0 {
limit = 160
}
k := Tokens(c)
d := 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")
}
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 {
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
})
FormField(c, msg(c, "profile_editor.bio"), FormFieldOptions{}, func() *ui.Element {
return TextArea(c, &p.Bio, TextAreaOptions{MaxLength: limit, Disabled: locked}).Input
})
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
if dirty {
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Grow(1).Role(ui.RoleStatus).Children(func() {
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)
})
} else {
ui.Box(c).Grow(1)
}
if Button(c, msg(c, "profile_editor.discard"), ButtonOptions{Variant: 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 {
r.save = true
}
})
})
if r.save || r.reset || r.avatar {
slog.Debug("mujica: profile_editor action", "save", r.save, "discard", r.reset, "avatar", r.avatar)
}
return r
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestProfileEditorUnsavedSaveAndDiscard(t *testing.T) {
saved := Profile{Name: "Sakiko", Bio: "Keyboard."}
p := saved
var saves, avatars, discards int
opts := ProfileEditorOptions{Saved: saved}
tt := accountTester(480, 500, func(c *ui.Context) {
r := ProfileEditor(c, &p, opts)
if r.Save() {
saves++
}
if r.ChangeAvatar() {
avatars++
}
if r.Discarded() {
discards++
}
})
if tt.HasText("Unsaved changes") {
t.Fatal("clean profile marked unsaved")
}
_ = tt.Click("Save")
if saves != 0 {
t.Fatal("saved without changes")
}
_ = tt.Click("Name")
tt.Type(" Togawa")
if !tt.HasText("Unsaved changes") || p.Name != "Sakiko Togawa" {
t.Fatalf("edit: %q", p.Name)
}
_ = tt.Click("Save")
_ = tt.Click("Change picture")
if saves != 1 || avatars != 1 {
t.Fatalf("saves %d avatars %d", saves, avatars)
}
_ = tt.Click("Discard")
if p != saved || discards != 1 || tt.HasText("Unsaved changes") {
t.Fatalf("discard: %+v", p)
}
_ = tt.Click("Name")
tt.Key(ui.Cmd, ui.KeyA)
tt.Key(0, ui.KeyBackspace)
_ = tt.Click("Save")
if saves != 1 || !tt.HasText("Required") {
t.Fatal("saved an empty name")
}
}
func TestProfileEditorSavingBlocksEdits(t *testing.T) {
p := Profile{Name: "Uika"}
tt := accountTester(480, 500, func(c *ui.Context) {
ProfileEditor(c, &p, ProfileEditorOptions{Saved: Profile{Name: "Misumi"}, Saving: true})
})
_ = tt.Click("Name")
tt.Type("x")
if p.Name != "Uika" {
t.Fatal("edited while saving")
}
}
func TestProfileEditorSnapshot(t *testing.T) {
snapshot(t, "profile_editor", 460, 400, func(c *ui.Context) {
p := ui.Local(c.Root(), "p", func() Profile { return Profile{Name: "Sakiko Togawa", Bio: "Keyboard, Ave Mujica."} })
ui.Column(c).Padding(16).Children(func() { ProfileEditor(c, p, ProfileEditorOptions{Saved: Profile{Name: "Sakiko"}}) })
})
}
func TestProfileEditorDisabledAndBioLimit(t *testing.T) {
p := Profile{Name: "Uika"}
disabled := false
saves := 0
tt := accountTester(600, 500, func(c *ui.Context) {
if ProfileEditor(c, &p, ProfileEditorOptions{Saved: Profile{Name: "Uika"}, BioLimit: 3, Disabled: disabled}).Save() {
saves++
}
})
if err := tt.Click("Bio"); err != nil {
t.Fatal(err)
}
tt.Type("初音未来")
if err := tt.Click("Save"); err != nil {
t.Fatal(err)
}
if p.Bio != "初音未来" || saves != 0 {
t.Fatal("over-limit bio is saved or modified")
}
disabled = true
tt.Frame()
tt.Type("edit")
if err := tt.Click("Discard"); err != nil {
t.Fatal(err)
}
if p.Bio != "初音未来" {
t.Fatal("disabled profile changes")
}
accountPanics(t, func(c *ui.Context) { ProfileEditor(c, &p, ProfileEditorOptions{BioLimit: -1}) })
}
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Session describes a device without carrying its authentication token.
type Session struct {
ID, Device, Location string
LastActive time.Time
// Current marks the session this window runs in.
Current bool
}
// SessionListOptions configure a SessionList.
type SessionListOptions struct {
Disabled bool
}
// SessionListResult reports individual and bulk sign-out requests.
type SessionListResult struct {
Element *ui.Element
revoke string
others bool
}
// Revoked returns the ID of a session to sign out.
func (r SessionListResult) Revoked() (string, bool) { return r.revoke, r.revoke != "" }
// OthersRevoked reports confirmed bulk sign-out.
func (r SessionListResult) OthersRevoked() bool { return r.others }
type sessionListState struct {
confirm bool
source []Session
others int
ready bool
}
// 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)
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{} })
disabled := r.Element.IsDisabled()
if !st.ready || !slices.Equal(st.source, sessions) {
st.source, st.others, st.ready = slices.Clone(sessions), 0, true
ids := map[string]bool{}
for _, s := range sessions {
if s.ID == "" || s.Device == "" || ids[s.ID] {
panic("mujica: Session needs a unique ID and a Device")
}
ids[s.ID] = true
if !s.Current {
st.others++
}
}
}
others := st.others
r.Element.Children(func() {
ui.Column(c).Role(ui.RoleList).Label(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).
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Role(ui.RoleListItem).Children(func() {
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
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)
}
})
ui.Text(c, sessionListDetail(c, s)).TextColor(k.TextMuted).FontSize(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))
if b.Clicked() && !disabled {
r.revoke = s.ID
}
}
})
})
})
ui.Row(c).Children(func() {
if Button(c, msg(c, "session_list.sign_out_others"), ButtonOptions{Variant: Danger, Disabled: disabled || others == 0}).Clicked() && !disabled {
st.confirm = true
}
})
})
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")})
r.others = res.Confirmed && !disabled
if r.revoke != "" || r.others {
slog.Debug("mujica: session_list sign out", "others", r.others)
}
return r
}
// sessionListDetail formats the location and last active time.
func sessionListDetail(c *ui.Context, s Session) string {
when := msg(c, "session_list.now")
if ago := c.Now().Sub(s.LastActive); s.LastActive.IsZero() {
when = msg(c, "session_list.unknown")
} else if ago >= time.Minute {
when = s.LastActive.Format("2006-01-02 15:04")
}
if s.Location == "" {
return when
}
return s.Location + " · " + when
}
+80
View File
@@ -0,0 +1,80 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func sessionSample(now time.Time) []Session {
return []Session{
{ID: "s1", Device: "MacBook Pro · Safari", Location: "Tokyo", LastActive: now, Current: true},
{ID: "s2", Device: "iPhone · App", Location: "Osaka", LastActive: time.Date(2026, 10, 1, 9, 30, 0, 0, time.Local)},
{ID: "s3", Device: "Windows · Edge"},
}
}
func TestSessionListRevokeOneAndOthers(t *testing.T) {
var revoked []string
others := 0
disabled := false
tt := accountTester(600, 500, func(c *ui.Context) {
r := SessionList(c, sessionSample(c.Now()), SessionListOptions{Disabled: disabled})
if id, ok := r.Revoked(); ok {
revoked = append(revoked, id)
}
if r.OthersRevoked() {
others++
}
})
for _, want := range []string{"This device", "Tokyo · Active now", "Osaka · 2026-10-01 09:30", "Last active unknown"} {
if !tt.HasText(want) {
t.Fatalf("missing %q in %q", want, tt.Texts())
}
}
if _, ok := tt.Find("Sign out of MacBook Pro · Safari"); ok {
t.Fatal("current session offers sign out")
}
if err := tt.Click("Sign out of iPhone · App"); err != nil {
t.Fatal(err)
}
_ = tt.Click("Sign out of other sessions")
if !tt.Focused("Cancel") || !tt.HasText("2 other sessions will need to sign in again.") {
t.Fatalf("confirm: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter)
if others != 0 {
t.Fatal("Enter on Cancel signed out")
}
_ = tt.Click("Sign out of other sessions")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(revoked) != 1 || revoked[0] != "s2" || others != 1 {
t.Fatalf("revoked %v others %d", revoked, others)
}
disabled = true
tt.Frame()
_ = tt.Click("Sign out of Windows · Edge")
if len(revoked) != 1 {
t.Fatal("disabled list signed out")
}
}
func TestSessionListSnapshot(t *testing.T) {
snapshot(t, "session_list", 560, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { SessionList(c, sessionSample(c.Now()), SessionListOptions{}) })
})
}
func TestSessionListLargeDataBuildsVisibleRows(t *testing.T) {
items := make([]Session, 100000)
for i := range items {
items[i] = Session{ID: fmt.Sprint(i), Device: fmt.Sprintf("Device %d", i), Current: i == 0}
}
tt := accountTester(600, 500, func(c *ui.Context) { SessionList(c, items, SessionListOptions{}) })
if n := len(tt.Texts()); n > 100 {
t.Fatalf("built %d text elements for a bounded viewport", n)
}
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// SettingsPage is one page of a SettingsLayout's navigation.
type SettingsPage struct {
ID, Label string
Icon *ui.SVG
}
// SettingsLayoutOptions configure a SettingsLayout.
type SettingsLayoutOptions struct {
// Title heads the navigation; empty uses the built-in "Settings".
Title string
}
// SettingsLayout builds section navigation and independently scrolling content.
func SettingsLayout(c *ui.Context, page *string, pages []SettingsPage, opts SettingsLayoutOptions, content func(id string)) ChoiceResult {
if len(pages) == 0 || content == nil {
panic("mujica: SettingsLayout needs pages and content")
}
found := false
items := make([]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}
}
if *page == "" {
*page, found = pages[0].ID, true
}
if !found {
panic("mujica: SettingsLayout page " + *page + " is not one of pages")
}
title := opts.Title
if title == "" {
title = msg(c, "settings_layout.title")
}
k := Tokens(c)
d := Density(c)
var r 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})
if nav.Changed() {
r.changed = 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() {
ui.Column(c).Key(*page).MaxWidth(720).Gap(d.Unit() * 8).Children(func() { content(*page) })
})
})
return r
}
// SettingsSection groups rows under a serif heading.
func SettingsSection(c *ui.Context, title string, rows func()) *ui.Element {
k := Tokens(c)
d := 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.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)
})
return e
}
// SettingsRowOptions configure a SettingsRow.
type SettingsRowOptions struct {
Description string
// ID identifies this row for SettingsSearch navigation.
ID string
}
type settingsRevealKey struct{}
// settingsReveal queues focus and scrolling for the matching row.
func settingsReveal(c *ui.Context, id string) {
*ui.Local(c.Root(), settingsRevealKey{}, func() string { return "" }) = id
}
type settingsRowState struct{ at time.Time }
// SettingsRow associates a label and description with its control.
func SettingsRow(c *ui.Context, label string, opts SettingsRowOptions, control func() *ui.Element) *ui.Element {
if label == "" || control == nil {
panic("mujica: SettingsRow needs a label and a control")
}
k := Tokens(c)
d := 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)
}
st := ui.Local(row, "reveal", func() settingsRowState { return settingsRowState{} })
reveal := ui.Local(c.Root(), settingsRevealKey{}, func() string { return "" })
var ctrl, text *ui.Element
row.Children(func() {
text = ui.Column(c).Grow(1).MinWidth(0).Gap(d.Unit())
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.Row(c).Shrink(0).Children(func() { ctrl = control() })
})
if ctrl == nil {
panic("mujica: SettingsRow " + label + ": control returned nil")
}
ctrl.Label(label).Description(opts.Description)
if text.Clicked() && !ctrl.IsDisabled() {
ctrl.Focus()
}
if opts.ID != "" && *reveal == opts.ID {
*reveal = ""
st.at = c.Now()
ctrl.Focus().ScrollIntoView()
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 t := c.Now().Sub(st.at); t < dur {
row.Background(k.Selection.Alpha(1 - float32(t)/float32(dur)))
c.AnimationFrame()
}
}
return row
}
+103
View File
@@ -0,0 +1,103 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
// 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{})
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
}, w, h)
}
var settingsPages = []SettingsPage{{ID: "general", Label: "General"}, {ID: "privacy", Label: "Privacy"}}
func settingsView(c *ui.Context, page *string, sound, track *bool, changes *int) {
if SettingsLayout(c, page, settingsPages, SettingsLayoutOptions{}, func(id string) {
switch id {
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{})
})
})
case "privacy":
SettingsSection(c, "Data", func() {
SettingsRow(c, "Usage reports", SettingsRowOptions{ID: "track"}, func() *ui.Element {
return Switch(c, track, "", SwitchOptions{})
})
})
}
}).Changed() {
*changes++
}
}
func TestSettingsLayoutNavigatesAndLabelsRows(t *testing.T) {
page, sound, track, n := "", false, false, 0
tt := accountTester(900, 500, func(c *ui.Context) { settingsView(c, &page, &sound, &track, &n) })
if page != "general" || !tt.HasText("Chime on new messages.") {
t.Fatalf("first page: %q", page)
}
if err := tt.Click("Chime on new messages."); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeySpace)
if !tt.Focused("Sound") || !sound {
t.Fatal("row label does not reach its control")
}
if err := tt.Click("Privacy"); err != nil {
t.Fatal(err)
}
if page != "privacy" || n != 1 || !tt.HasText("Usage reports") || tt.HasText("Sound") {
t.Fatalf("page %q changes %d texts %q", page, n, tt.Texts())
}
}
func TestSettingsLayoutRejectsUnknownPage(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("unknown page accepted")
}
}()
page := "nope"
accountTester(400, 300, func(c *ui.Context) { SettingsLayout(c, &page, settingsPages, SettingsLayoutOptions{}, func(string) {}) })
}
func TestSettingsLayoutSnapshot(t *testing.T) {
snapshot(t, "settings_layout", 860, 360, func(c *ui.Context) {
st := ui.Local(c.Root(), "snap", func() [3]bool { return [3]bool{false, true, false} })
page := "general"
ui.Box(c).Fill().Children(func() { settingsView(c, &page, &st[1], &st[2], new(int)) })
})
}
func accountPanics(t *testing.T, view func(*ui.Context)) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatal("invalid options accepted")
}
}()
accountTester(600, 500, view)
}
func TestSettingsLayoutDisabledNavigationAndControl(t *testing.T) {
page, sound, track, changes := "general", false, false, 0
tt := accountTester(900, 500, func(c *ui.Context) {
ui.Column(c).Fill().Disabled(true).Children(func() { settingsView(c, &page, &sound, &track, &changes) })
})
if err := tt.Click("Privacy"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
if page != "general" || sound || track || changes != 0 {
t.Fatal("disabled settings change state")
}
}
+188
View File
@@ -0,0 +1,188 @@
package mujica
import (
"log/slog"
"reflect"
"slices"
"strings"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SettingEntry identifies a searchable setting and its destination.
type SettingEntry struct {
ID, Page string
Label string
Description string
// Keywords match too, as synonyms.
Keywords []string
}
// SettingsSearchOptions configure a SettingsSearch.
type SettingsSearchOptions struct {
// Limit caps the results shown; zero means 8.
Limit int
}
// SettingsSearchResult carries the selected destination.
type SettingsSearchResult struct {
Element *ui.Element
chosen *SettingEntry
}
// Chosen returns the selected entry and reveals its row.
func (r SettingsSearchResult) Chosen() (SettingEntry, bool) {
if r.chosen == nil {
return SettingEntry{}, false
}
return *r.chosen, true
}
type settingsSearchState struct {
cursor int
source []SettingEntry
query string
limit int
hits []int
ready bool
}
// SettingsSearch highlights matches and navigates to settings.
func SettingsSearch(c *ui.Context, query *string, entries []SettingEntry, opts SettingsSearchOptions) SettingsSearchResult {
if opts.Limit < 0 {
panic("mujica: SettingsSearch Limit cannot be negative")
}
limit := opts.Limit
if limit == 0 {
limit = 8
}
d := 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")})
in := res.Input
q := strings.ToLower(strings.TrimSpace(*query))
settingsSearchPlan(st, entries, q, limit)
hits := st.hits
st.cursor = max(0, min(st.cursor, len(hits)-1))
if res.Changed() {
st.cursor = 0
}
disabled := in.IsDisabled()
before := st.cursor
switch {
case disabled || len(hits) == 0:
case in.Shortcut(0, ui.KeyDown):
st.cursor = (st.cursor + 1) % len(hits)
case in.Shortcut(0, ui.KeyUp):
st.cursor = (st.cursor + len(hits) - 1) % len(hits)
}
if res.Submitted() && len(hits) > 0 && !disabled {
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)
}
list := accountRows(c, len(hits), func(n int) any { return entries[hits[n]].ID }, func(n int) {
i := hits[n]
if settingsSearchHit(c, entries[i], q, n == st.cursor) {
r.chosen = &entries[i]
}
})
list.Role(ui.RoleList)
if len(hits) > 8 && (st.cursor != before || res.Changed()) {
ui.Local(list, "rows", func() ui.ListState { return ui.ListState{} }).ScrollIntoView(st.cursor)
}
})
if r.chosen != nil {
settingsReveal(c, r.chosen.ID)
*query, st.cursor = "", 0
slog.Debug("mujica: settings_search chosen", "id", r.chosen.ID)
}
return r
}
func settingsSearchPlan(st *settingsSearchState, entries []SettingEntry, q string, limit int) {
if st.ready && st.query == q && st.limit == limit && reflect.DeepEqual(st.source, entries) {
return
}
st.ready, st.query, st.limit = true, q, limit
st.source = slices.Clone(entries)
st.hits = st.hits[:0]
ids := map[string]bool{}
for i, e := range entries {
if e.ID == "" || e.Page == "" || e.Label == "" || ids[e.ID] {
panic("mujica: SettingEntry needs a unique ID, Page and Label")
}
ids[e.ID] = true
st.source[i].Keywords = slices.Clone(e.Keywords)
text := strings.ToLower(e.Label + " " + e.Description + " " + strings.Join(e.Keywords, " "))
if q != "" && strings.Contains(text, q) && len(st.hits) < limit {
st.hits = append(st.hits, i)
}
}
}
// 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)
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)
switch {
case current:
b.Background(k.Selection)
case b.Hovered():
b.Background(k.SurfaceHover)
}
b.Children(func() {
ui.Box(c).Size(8, 8).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if current {
p.FillPath(internal.Diamond(internal.Snap(p, r.X+4), internal.Snap(p, r.Y+4), 8), k.AccentText)
}
})
ui.Column(c).Gap(2).Children(func() {
ui.RichText(c, settingsSearchSpans(c, e.Label, q, theme.BodySize)...)
if e.Description != "" {
ui.RichText(c, settingsSearchSpans(c, e.Description, q, theme.CaptionSize)...).TextColor(k.TextMuted)
}
})
})
return b.Clicked() && !b.IsDisabled()
}
// 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)
plain := func(t string) ui.Span { return ui.Span{Text: t, Size: size, Color: k.Text} }
if q == "" {
return []ui.Span{plain(s)}
}
var out []ui.Span
low := strings.ToLower(s)
original := []rune(s)
for {
i := strings.Index(low, q)
if i < 0 {
break
}
start, count := utf8.RuneCountInString(low[:i]), utf8.RuneCountInString(q)
if start > 0 {
out = append(out, plain(string(original[:start])))
}
out = append(out, ui.Span{Text: string(original[start : start+count]), Size: size, Weight: 700, Background: k.Selection, Color: k.Text})
original, low = original[start+count:], low[i+len(q):]
}
if len(original) > 0 {
out = append(out, plain(string(original)))
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var settingEntries = []SettingEntry{
{ID: "sound", Page: "general", Label: "Sound", Description: "Chime on new messages.", Keywords: []string{"audio"}},
{ID: "track", Page: "privacy", Label: "Usage reports", Description: "Send anonymous usage."},
}
func TestSettingsSearchFindsHighlightsAndReveals(t *testing.T) {
page, query, sound, track, n := "privacy", "", false, false, 0
var chosen SettingEntry
tt := accountTester(1000, 600, func(c *ui.Context) {
ui.Row(c).Fill().Children(func() {
ui.Box(c).Width(300).Children(func() {
if e, ok := SettingsSearch(c, &query, settingEntries, SettingsSearchOptions{}).Chosen(); ok {
chosen, page = e, e.Page
}
})
ui.Box(c).Grow(1).FillHeight().Children(func() { settingsView(c, &page, &sound, &track, &n) })
})
})
_ = tt.Click("Search settings")
tt.Type("AUD")
if _, ok := tt.Find("Sound"); !ok || tt.HasText("Send anonymous usage.") {
t.Fatalf("keyword search: %q", tt.Texts())
}
_ = tt.Click("Search settings")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("ou")
if !tt.HasText("ou") || !tt.HasText("nd") || !tt.HasText("Usage reports") {
t.Fatalf("match not split for marking: %q", tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if chosen.ID != "sound" || page != "general" {
t.Fatalf("chose %q page %q", chosen.ID, page)
}
tt.Frame()
if !tt.Focused("Sound") || query != "" {
t.Fatalf("row not revealed, query %q", query)
}
_ = tt.Click("Search settings")
tt.Type("zzz")
if !tt.HasText("No settings match") {
t.Fatal("no empty result")
}
}
func TestSettingsSearchSnapshot(t *testing.T) {
snapshot(t, "settings_search", 360, 220, func(c *ui.Context) {
q := ui.Local(c.Root(), "q", func() string { return "us" })
ui.Column(c).Padding(16).Children(func() { SettingsSearch(c, q, settingEntries, SettingsSearchOptions{}) })
})
}
func TestSettingsSearchHighlightsUnicodeCaseMapping(t *testing.T) {
var spans []ui.Span
tt := accountTester(400, 220, func(c *ui.Context) {
q := "i"
spans = settingsSearchSpans(c, "İstanbul", q, 14)
SettingsSearch(c, &q, []SettingEntry{{ID: "language", Page: "general", Label: "İstanbul"}}, SettingsSearchOptions{})
})
if len(spans) != 2 || spans[0].Text != "İ" || spans[0].Weight != 700 || spans[1].Text != "stanbul" || !tt.HasText("İstanbul") {
t.Fatal("Unicode case mapping loses match highlighting")
}
}
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 {
t.Error("search text does not use the readable Text token")
}
}
})
}
func TestSettingsSearchCachedPlanSeesInPlaceChanges(t *testing.T) {
st := settingsSearchState{}
entries := []SettingEntry{{ID: "theme", Page: "general", Label: "Appearance", Keywords: []string{"theme"}}}
settingsSearchPlan(&st, entries, "theme", 8)
if len(st.hits) != 1 {
t.Fatal("initial keyword fails")
}
before := &st.hits[0]
settingsSearchPlan(&st, entries, "theme", 8)
if before != &st.hits[0] {
t.Fatal("unchanged data rebuilt search plan")
}
entries[0].Keywords[0] = "color"
settingsSearchPlan(&st, entries, "theme", 8)
if len(st.hits) != 0 {
t.Fatal("cached search misses in-place keyword change")
}
}
func TestSettingsSearchDisabledAndLimit(t *testing.T) {
q, chosen := "o", 0
tt := accountTester(600, 500, func(c *ui.Context) {
ui.Column(c).Disabled(true).Children(func() {
if _, ok := SettingsSearch(c, &q, settingEntries, SettingsSearchOptions{Limit: 1}).Chosen(); ok {
chosen++
}
})
})
if err := tt.Click("Sound"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Type("edited")
if q != "o" || chosen != 0 || tt.HasText("Send anonymous usage.") {
t.Fatal("disabled or limited search acts incorrectly")
}
accountPanics(t, func(c *ui.Context) { SettingsSearch(c, &q, settingEntries, SettingsSearchOptions{Limit: -1}) })
}
+217
View File
@@ -0,0 +1,217 @@
package mujica
import (
"fmt"
"log/slog"
"runtime"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// Shortcut combines modifiers and a key; zero means unassigned.
type Shortcut struct {
Mods ui.Modifiers
Key ui.Key
}
// IsZero reports an empty shortcut.
func (s Shortcut) IsZero() bool { return s == Shortcut{} }
// keys returns platform-ordered key names.
func (s Shortcut) keys() []string {
var out []string
mac := runtime.GOOS == "darwin"
add := func(m ui.Modifiers, name string) {
if s.Mods&m != 0 {
out = append(out, name)
}
}
if mac {
add(ui.Ctrl, "ctrl")
add(ui.Alt, "alt")
add(ui.Shift, "shift")
add(ui.Super, "mod")
} else {
add(ui.Ctrl, "ctrl")
add(ui.Super, "Win")
add(ui.Alt, "alt")
add(ui.Shift, "shift")
}
return append(out, shortcutKeyName(s.Key))
}
// String spells s as keys joined by "+", as "ctrl+shift+K".
func (s Shortcut) String() string {
if s.IsZero() {
return ""
}
return strings.Join(s.keys(), "+")
}
// shortcutKeyName is the Kbd name of key.
func shortcutKeyName(k ui.Key) string {
switch {
case k >= ui.KeyA && k <= ui.KeyZ:
return string(rune('A' + k - ui.KeyA))
case k >= ui.Key0 && k <= ui.Key9:
return string(rune('0' + k - ui.Key0))
case k >= ui.KeyF1 && k <= ui.KeyF12:
return fmt.Sprintf("F%d", k-ui.KeyF1+1)
}
if n, ok := shortcutNames[k]; ok {
return n
}
panic(fmt.Sprintf("mujica: Shortcut key %d has no name", k))
}
var shortcutNames = map[ui.Key]string{
ui.KeyEnter: "enter", ui.KeyEscape: "esc", ui.KeyBackspace: "backspace", ui.KeyTab: "tab", ui.KeySpace: "Space",
ui.KeyDelete: "delete", ui.KeyHome: "Home", ui.KeyEnd: "End", ui.KeyPageUp: "PgUp", ui.KeyPageDown: "PgDn",
ui.KeyLeft: "left", ui.KeyRight: "right", ui.KeyUp: "up", ui.KeyDown: "down", ui.KeyMinus: "-", ui.KeyEqual: "=",
ui.KeyComma: ",", ui.KeyPeriod: ".", ui.KeySlash: "/", ui.KeySemicolon: ";", ui.KeyQuote: "'",
ui.KeyBracketLeft: "[", ui.KeyBracketRight: "]", ui.KeyBackslash: "\\", ui.KeyBackquote: "`",
ui.KeyInsert: "Insert", ui.KeyContextMenu: "Menu", ui.KeyBack: "Back", ui.KeyForward: "Forward",
}
// 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)
return
}
Kbd(c, s.keys()...)
}
// ShortcutBinding names an action that already owns a shortcut.
type ShortcutBinding struct {
Action string
Shortcut Shortcut
}
// ShortcutRecorderOptions configure a ShortcutRecorder.
type ShortcutRecorderOptions struct {
// Taken lists occupied shortcuts; conflicts preserve the previous value.
Taken []ShortcutBinding
// Label names the recorder, as the action it sets.
Label string
Disabled bool
}
type shortcutRecorderState struct {
recording, needMod bool
caught *Shortcut
conflict string
}
// ShortcutRecorder records key combinations; Escape cancels.
func ShortcutRecorder(c *ui.Context, value *Shortcut, opts ShortcutRecorderOptions) ChoiceResult {
if opts.Label == "" {
panic("mujica: ShortcutRecorder needs a Label")
}
d := Density(c)
var r 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).
Radius(theme.ControlRadius).Justify(ui.SpaceBetween)
st := ui.Local(b, "rec", func() shortcutRecorderState { return shortcutRecorderState{} })
disabled := b.IsDisabled()
if disabled || !b.Focused() {
st.recording, st.caught = false, nil
}
if st.caught != nil {
s := *st.caught
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)
}
}
if st.conflict == "" && s != *value {
*value, r.changed = s, true
slog.Debug("mujica: shortcut_recorder set", "shortcut", s.String())
}
}
if b.Clicked() && !disabled {
st.recording, st.conflict, st.needMod = true, "", false
slog.Debug("mujica: shortcut_recorder recording")
}
if st.recording {
b.HandleInput(func(ev ui.InputEvent) bool {
if b.IsDisabled() {
return false
}
return shortcutRecorderKey(st, ev)
})
}
shortcutRecorderLook(c, b, st, *value, opts.Label)
shortcutRecorderNote(c, st)
})
return r
}
// shortcutRecorderKey captures keys during recording.
func shortcutRecorderKey(st *shortcutRecorderState, ev ui.InputEvent) bool {
switch {
case ev.Kind == ui.InputText:
return true
case ev.Kind != ui.InputKeyDown || ev.Key == ui.KeyUnknown:
return false
case ev.Key == ui.KeyTab && ev.Mods&^ui.Shift == 0:
return false
case ev.Key == ui.KeyEscape && ev.Mods == 0:
st.recording = false
case ev.Mods&^ui.Shift == 0 && !(ev.Key >= ui.KeyF1 && ev.Key <= ui.KeyF12):
st.needMod = true
default:
s := Shortcut{ev.Mods, ev.Key}
st.caught = &s
}
return true
}
// shortcutRecorderLook displays the shortcut or recording prompt.
func shortcutRecorderLook(c *ui.Context, b *ui.Element, st *shortcutRecorderState, value Shortcut, name string) {
k := Tokens(c)
internal.Ring(b, k.Focus, theme.ControlRadius)
label := name + ": " + value.String()
if value.IsZero() {
label = name + ": " + msg(c, "shortcut_recorder.none")
}
b.Label(label)
bg, border := k.Surface, k.ControlBorder
if st.recording {
bg, border = k.Selection, k.AccentText
} else if b.Hovered() {
bg = k.SurfaceHover
}
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()
} else {
shortcutKbd(c, value)
}
ui.Icon(c, icons.Must("command")).Size(14, 14).TextColor(k.TextMuted)
})
}
// shortcutRecorderNote displays conflict and modifier guidance.
func shortcutRecorderNote(c *ui.Context, st *shortcutRecorderState) {
text := st.conflict
if st.needMod && st.recording {
text = 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.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize))
})
}
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func shortcutRecorderTester(v *Shortcut, opts *ShortcutRecorderOptions, n *int) *ui.Tester {
return accountTester(400, 160, func(c *ui.Context) {
if ShortcutRecorder(c, v, *opts).Changed() {
*n++
}
})
}
func TestShortcutRecorderRecordsAndCancels(t *testing.T) {
v, n := Shortcut{}, 0
opts := ShortcutRecorderOptions{Label: "Open palette"}
tt := shortcutRecorderTester(&v, &opts, &n)
if _, ok := tt.Find("Open palette: none"); !ok {
t.Fatal("empty value not named")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Press keys…") {
t.Fatalf("not recording: %q", tt.Texts())
}
tt.TypeKey(0, ui.KeyK, "k")
if !tt.HasText("Add Ctrl, Alt or Command; F-keys may stand alone.") || n != 0 {
t.Fatal("bare letter accepted")
}
tt.Key(ui.Ctrl|ui.Shift, ui.KeyK)
if v != (Shortcut{ui.Ctrl | ui.Shift, ui.KeyK}) || n != 1 || tt.HasText("Press keys…") {
t.Fatalf("recorded %+v", v)
}
if v.String() != "ctrl+shift+K" {
t.Fatalf("spelled %q", v.String())
}
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyF5)
if v.Key != ui.KeyK || tt.HasText("Press keys…") {
t.Fatal("escape did not cancel")
}
}
func TestShortcutRecorderConflictAndDisabled(t *testing.T) {
v, n := Shortcut{ui.Ctrl, ui.KeyP}, 0
opts := ShortcutRecorderOptions{Label: "Print", Taken: []ShortcutBinding{{Action: "Save", Shortcut: Shortcut{ui.Ctrl, ui.KeyS}}}}
tt := shortcutRecorderTester(&v, &opts, &n)
_ = tt.Click("Print: ctrl+P")
tt.Key(ui.Ctrl, ui.KeyS)
if v.Key != ui.KeyP || n != 0 || !tt.HasText("ctrl+S is already used by Save") {
t.Fatalf("conflict: %+v %q", v, tt.Texts())
}
tt.Key(0, ui.KeySpace)
opts.Disabled = true
tt.Key(ui.Ctrl, ui.KeyJ)
if v.Key != ui.KeyP {
t.Fatal("disabled recorder recorded")
}
}
func TestShortcutRecorderSnapshot(t *testing.T) {
snapshot(t, "shortcut_recorder", 300, 110, func(c *ui.Context) {
a := ui.Local(c.Root(), "a", func() Shortcut { return Shortcut{ui.Ctrl | ui.Shift, ui.KeyK} })
b := ui.Local(c.Root(), "b", func() Shortcut { return Shortcut{} })
ui.Column(c).Padding(16).Gap(12).Children(func() {
ShortcutRecorder(c, a, ShortcutRecorderOptions{Label: "Command palette"})
ShortcutRecorder(c, b, ShortcutRecorderOptions{Label: "Zen mode"})
})
})
}
func TestShortcutRecorderFunctionKeyAndTabCancellation(t *testing.T) {
v, n := Shortcut{}, 0
opts := ShortcutRecorderOptions{Label: "Refresh"}
tt := accountTester(400, 200, func(c *ui.Context) {
if ShortcutRecorder(c, &v, opts).Changed() {
n++
}
Button(c, "Next control", ButtonOptions{})
})
if err := tt.Click("Refresh: none"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyF5)
if v != (Shortcut{Key: ui.KeyF5}) || n != 1 {
t.Fatal("standalone function key fails")
}
if err := tt.Click("Refresh: F5"); err != nil {
t.Fatal(err)
}
tt.Key(0, ui.KeyTab)
tt.Key(ui.Ctrl, ui.KeyP)
if v.Key != ui.KeyF5 || n != 1 {
t.Fatal("recorder retains keys after Tab")
}
accountPanics(t, func(c *ui.Context) { ShortcutRecorder(c, &v, ShortcutRecorderOptions{}) })
}
func TestShortcutRecorderNamesEveryDeliveredKey(t *testing.T) {
for _, key := range []ui.Key{ui.KeyInsert, ui.KeyContextMenu, ui.KeyBack, ui.KeyForward} {
t.Run(fmt.Sprint(key), func(t *testing.T) {
defer func() {
if recover() != nil {
t.Fatal("recording a valid MyGo key panicked")
}
}()
v, n := Shortcut{}, 0
opts := ShortcutRecorderOptions{Label: "Action"}
tt := shortcutRecorderTester(&v, &opts, &n)
if err := tt.Click("Action: none"); err != nil {
t.Fatal(err)
}
tt.Key(ui.Ctrl, key)
if v != (Shortcut{ui.Ctrl, key}) || n != 1 || v.String() == "" {
t.Fatal("valid key was not recorded and named")
}
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+88
View File
@@ -0,0 +1,88 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ThemeSelectorOptions configure a ThemeSelector.
type ThemeSelectorOptions struct {
Disabled bool
}
// ThemeSelector selects an appearance using preview cards.
func ThemeSelector(c *ui.Context, mode *Mode, opts ThemeSelectorOptions) ChoiceResult {
if *mode != System && *mode != Light && *mode != 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")).
FocusGroup(ui.Horizontal).Disabled(opts.Disabled)
selected := mode
if r.Element.IsDisabled() {
copy := *mode
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)
if card.Changed() && !card.IsDisabled() {
r.changed = true
slog.Debug("mujica: theme_selector chose", "mode", m.String())
}
on := *mode == m
internal.Ring(card, k.Focus, theme.CardRadius)
border, width := k.Border, theme.BorderWidth
switch {
case on:
border, width = k.AccentText, 2
card.Background(k.Selection)
case card.Hovered():
card.Background(k.SurfaceHover)
}
card.Border(width, border)
card.Children(func() {
ui.Box(c).Size(132, 84).Role(ui.RoleNone).Draw(func(p *ui.Painter, rect ui.Rect) {
switch m {
case Light:
themePreview(p, rect, theme.Light())
case Dark:
themePreview(p, rect, theme.Dark())
default:
half := ui.Rect{X: rect.X, Y: rect.Y, W: rect.W / 2, H: rect.H}
p.Clip(half, 0, func() { themePreview(p, rect, theme.Light()) })
p.Clip(ui.Rect{X: half.X + half.W, Y: rect.Y, W: rect.W / 2, H: rect.H}, 0, func() { themePreview(p, rect, theme.Dark()) })
}
})
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
if on {
ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.AccentText)
}
ui.Text(c, msg(c, "theme_selector."+m.String())).SingleLine()
})
})
}
})
return r
}
// themePreview paints a miniature themed window.
func themePreview(p *ui.Painter, r ui.Rect, t theme.Tokens) {
p.Fill(r, t.Background, theme.ControlRadius)
p.Stroke(r, t.Border, theme.ControlRadius, 1)
p.Fill(ui.Rect{X: r.X + 1, Y: r.Y + 1, W: r.W - 2, H: 12}, t.Surface, theme.ControlRadius)
p.Fill(ui.Rect{X: r.X + 1, Y: r.Y + 13, W: r.W * 0.28, H: r.H - 14}, t.Surface, 0)
x := r.X + r.W*0.36
for i, w := range []float32{0.5, 0.38, 0.44} {
p.Fill(ui.Rect{X: x, Y: r.Y + 22 + float32(i)*10, W: r.W * w, H: 4}, t.TextMuted, 2)
}
p.Fill(ui.Rect{X: x, Y: r.Y + r.H - 20, W: 30, H: 12}, t.Accent, 2)
}
+48
View File
@@ -0,0 +1,48 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestThemeSelectorPicksByPointerAndKeys(t *testing.T) {
mode, n := System, 0
disabled := false
tt := accountTester(600, 220, func(c *ui.Context) {
if ThemeSelector(c, &mode, ThemeSelectorOptions{Disabled: disabled}).Changed() {
n++
}
})
if err := tt.Click("Dark"); err != nil {
t.Fatal(err)
}
if mode != 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 {
t.Fatalf("keys: %v %d", mode, n)
}
disabled = true
tt.Frame()
_ = tt.Click("System")
if mode != Light {
t.Fatal("disabled selector changed")
}
defer func() {
if recover() == nil {
t.Fatal("unknown mode accepted")
}
}()
bad := 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 })
ui.Column(c).Padding(16).Children(func() { ThemeSelector(c, m, ThemeSelectorOptions{}) })
})
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// TwoFactorInputOptions configure a TwoFactorInput.
type TwoFactorInputOptions struct {
// ResendAt sets when resending becomes available.
ResendAt time.Time
// Error displays the caller-provided verification failure.
Error string
Disabled bool
}
// TwoFactorResult reports completion and resend events.
type TwoFactorResult struct {
Element *ui.Element
completed, resend bool
}
// Completed reports a transition to six filled digits.
func (r TwoFactorResult) Completed() bool { return r.completed }
// Resend reports a request for a new code.
func (r TwoFactorResult) Resend() bool { return r.resend }
// TwoFactorInput collects six digits and displays a resend countdown.
func TwoFactorInput(c *ui.Context, code *string, opts TwoFactorInputOptions) TwoFactorResult {
if code == nil || len(*code) > 6 || strings.Trim(*code, "0123456789") != "" {
panic("mujica: TwoFactorInput needs up to six digits")
}
k := Tokens(c)
d := 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 != ""})
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))
})
}
wait := opts.ResendAt.Sub(c.Now())
label := 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)
// 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})
if b.Clicked() && !disabled && wait <= 0 {
r.resend = true
slog.Debug("mujica: two_factor_input resend")
}
})
})
if r.completed {
slog.Debug("mujica: two_factor_input completed")
}
return r
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestTwoFactorInputPasteCompleteAndResend(t *testing.T) {
logs := accountLogs(t)
code, done, resent := "", 0, 0
opts := TwoFactorInputOptions{ResendAt: time.Now().Add(-time.Second)}
tt := accountTester(420, 220, func(c *ui.Context) {
r := TwoFactorInput(c, &code, opts)
if r.Completed() {
done++
}
if r.Resend() {
resent++
}
})
tt.Key(0, ui.KeyTab)
tt.SetClipboard("381 942")
tt.Command("paste")
if code != "381942" || done != 1 {
t.Fatalf("paste: %d digits, %d completions", len(code), done)
}
if strings.Contains(logs.String(), "381942") {
t.Fatal("the code reached the log")
}
if err := tt.Click("Resend code"); err != nil {
t.Fatal(err)
}
if resent != 1 {
t.Fatal("resend not reported")
}
opts.ResendAt = time.Now().Add(65 * time.Second)
opts.Error = "That code has expired."
tt.Frame()
if !tt.HasText("Resend in 1:05") && !tt.HasText("Resend in 1:04") || !tt.HasText("That code has expired.") {
t.Fatalf("countdown: %q", tt.Texts())
}
_ = tt.Click("Resend in 1:05")
if resent != 1 {
t.Fatal("resent during countdown")
}
}
func TestTwoFactorInputDisabled(t *testing.T) {
code := "12"
tt := accountTester(420, 220, func(c *ui.Context) {
TwoFactorInput(c, &code, TwoFactorInputOptions{Disabled: true})
})
tt.Key(0, ui.KeyTab)
tt.Type("3456")
if code != "12" {
t.Fatal("disabled input changed")
}
}
func TestTwoFactorInputSnapshot(t *testing.T) {
snapshot(t, "two_factor_input", 420, 160, func(c *ui.Context) {
code := ui.Local(c.Root(), "code", func() string { return "3819" })
ui.Column(c).Padding(16).Children(func() {
TwoFactorInput(c, code, TwoFactorInputOptions{ResendAt: c.Now().Add(42 * time.Second)})
})
})
}
func TestTwoFactorInputCountdownRoundsUp(t *testing.T) {
tt := accountTester(420, 220, func(c *ui.Context) {
code := ""
TwoFactorInput(c, &code, TwoFactorInputOptions{ResendAt: c.Now().Add(100 * time.Millisecond)})
})
if !tt.HasText("Resend in 0:01") {
t.Fatal("positive wait must show one remaining second")
}
}
func TestTwoFactorInputInvalidValueKeepsSecretOutOfPanic(t *testing.T) {
const secret = "1234567"
defer func() {
v := recover()
if v == nil {
t.Fatal("oversized code accepted")
}
if strings.Contains(fmt.Sprint(v), secret) {
t.Fatal("code appears in panic")
}
}()
code := secret
accountTester(420, 220, func(c *ui.Context) { TwoFactorInput(c, &code, TwoFactorInputOptions{}) })
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more