WIP components (as built)
No files matched your search
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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{}}) })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}) })
|
||||
}
|
||||
@@ -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。
|
||||
@@ -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 跳转;也可点击结果。
|
||||
|
||||
## 限制
|
||||
|
||||
大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。
|
||||
|
||||
- 匹配不区分大小写,按子串查找。
|
||||
@@ -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 文字计算;只标记,不阻止选择。
|
||||
@@ -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 使用符号。
|
||||
@@ -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。
|
||||
- 提交失败后需重新输入密码。
|
||||
@@ -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 选择。
|
||||
|
||||
## 限制
|
||||
|
||||
- 登录流程由调用方完成。
|
||||
@@ -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 到重新发送按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。
|
||||
- 倒计时每秒刷新一次,结束后不再重绘。
|
||||
@@ -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。
|
||||
@@ -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, ¤t, 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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
大集合使用虚拟列表,仅构建当前可见行。
|
||||
|
||||
- 菜单项只显示文字,头像只在按钮上。
|
||||
@@ -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, ¤t, spaces, mujica.WorkspaceSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `current` | `*string` | 必填 | 当前工作区 ID。 |
|
||||
| `spaces` | `[]Workspace` | 必填 | ID、Name 必填,Detail 可选。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
|
||||
## 状态
|
||||
|
||||
关闭、展开、搜索、无结果、当前项勾选、禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`ChoiceResult.Changed()`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
打开后焦点在搜索框;↑/↓ 移动,Enter 切换,Escape 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。
|
||||
|
||||
- 无。
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 按钮名称包含用户名与状态。
|
||||
@@ -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 到各注销按钮;确认框默认聚焦取消。
|
||||
|
||||
## 限制
|
||||
|
||||
大集合使用虚拟列表,仅构建当前可见行。
|
||||
|
||||
- 注销由调用方执行。
|
||||
@@ -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 创建;复制按钮写入剪贴板并播报“已复制”。
|
||||
|
||||
## 限制
|
||||
|
||||
大集合使用虚拟列表,仅构建当前可见行。
|
||||
|
||||
- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。
|
||||
- 明文作为可选择文字显示一次,屏幕阅读器可读出文字本身。
|
||||
- 创建进行中等待调用方结果;禁用关闭全部弹层并清除已显示明文。
|
||||
- 待处理请求跨禁用保留;失败后恢复名称与错误,支持重试。
|
||||
@@ -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 | 达到该比例即提示接近上限。 |
|
||||
|
||||
## 状态
|
||||
|
||||
正常、接近上限、已达上限。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点;每项以计量角色读出。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 在按钮与内容间移动。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 定位,空间不足时翻转到锚点上方。
|
||||
@@ -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 关闭。
|
||||
|
||||
## 限制
|
||||
|
||||
- 无。
|
||||
@@ -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 到内容与按钮。
|
||||
|
||||
## 限制
|
||||
|
||||
- 截图采集由调用方完成。
|
||||
@@ -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, ¤t, 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, ¤t, 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t, 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, ¤t, 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)`,
|
||||
}
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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] = ©
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}})
|
||||
})
|
||||
}
|
||||
@@ -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", "清空"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,14 @@ func overlayEscape(c *ui.Context, key any) bool {
|
||||
// The root is around every focus, so it takes Escape before MyGo's overlays.
|
||||
byFocus := c.Root().Shortcut(0, ui.KeyEscape)
|
||||
byWindow := c.Shortcut(0, ui.KeyEscape)
|
||||
return byFocus || byWindow
|
||||
if !byFocus && !byWindow {
|
||||
return false
|
||||
}
|
||||
s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} })
|
||||
last := len(s.keys) - 1
|
||||
s.keys[last] = nil
|
||||
s.keys, s.seen = s.keys[:last], s.seen[:last]
|
||||
return true
|
||||
}
|
||||
|
||||
// overlaySiteState follows the focus of a layer after it closes.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}) })
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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 = ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||