diff --git a/accent_color_picker.go b/accent_color_picker.go new file mode 100644 index 0000000..7fed104 --- /dev/null +++ b/accent_color_picker.go @@ -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) +} diff --git a/accent_color_picker_test.go b/accent_color_picker_test.go new file mode 100644 index 0000000..e59e3f0 --- /dev/null +++ b/accent_color_picker_test.go @@ -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{}}) }) +} diff --git a/account_switcher.go b/account_switcher.go new file mode 100644 index 0000000..786c654 --- /dev/null +++ b/account_switcher.go @@ -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 +} diff --git a/account_switcher_test.go b/account_switcher_test.go new file mode 100644 index 0000000..047032a --- /dev/null +++ b/account_switcher_test.go @@ -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) + } +} diff --git a/api_key_manager.go b/api_key_manager.go new file mode 100644 index 0000000..01c35e1 --- /dev/null +++ b/api_key_manager.go @@ -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 +} diff --git a/api_key_manager_test.go b/api_key_manager_test.go new file mode 100644 index 0000000..3b58882 --- /dev/null +++ b/api_key_manager_test.go @@ -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") + } + }) + } +} diff --git a/coachmark.go b/coachmark.go new file mode 100644 index 0000000..f436519 --- /dev/null +++ b/coachmark.go @@ -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 +} diff --git a/coachmark_test.go b/coachmark_test.go new file mode 100644 index 0000000..648bd15 --- /dev/null +++ b/coachmark_test.go @@ -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"}}) }) +} diff --git a/docs/components/461-settings-layout.md b/docs/components/461-settings-layout.md new file mode 100644 index 0000000..295bee6 --- /dev/null +++ b/docs/components/461-settings-layout.md @@ -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。 diff --git a/docs/components/462-settings-search.md b/docs/components/462-settings-search.md new file mode 100644 index 0000000..3a87a89 --- /dev/null +++ b/docs/components/462-settings-search.md @@ -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 跳转;也可点击结果。 + +## 限制 + +大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。 + +- 匹配不区分大小写,按子串查找。 diff --git a/docs/components/463-theme-selector.md b/docs/components/463-theme-selector.md new file mode 100644 index 0000000..f5531f2 --- /dev/null +++ b/docs/components/463-theme-selector.md @@ -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 选择。 + +## 限制 + +- 无。 diff --git a/docs/components/464-accent-color-picker.md b/docs/components/464-accent-color-picker.md new file mode 100644 index 0000000..1a94706 --- /dev/null +++ b/docs/components/464-accent-color-picker.md @@ -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 文字计算;只标记,不阻止选择。 diff --git a/docs/components/465-shortcut-recorder.md b/docs/components/465-shortcut-recorder.md new file mode 100644 index 0000000..1824e88 --- /dev/null +++ b/docs/components/465-shortcut-recorder.md @@ -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)无法录制。 diff --git a/docs/components/466-keyboard-shortcuts-list.md b/docs/components/466-keyboard-shortcuts-list.md new file mode 100644 index 0000000..03d299b --- /dev/null +++ b/docs/components/466-keyboard-shortcuts-list.md @@ -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 使用符号。 diff --git a/docs/components/467-login-form.md b/docs/components/467-login-form.md new file mode 100644 index 0000000..7747215 --- /dev/null +++ b/docs/components/467-login-form.md @@ -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。 +- 提交失败后需重新输入密码。 diff --git a/docs/components/468-oauth-buttons.md b/docs/components/468-oauth-buttons.md new file mode 100644 index 0000000..12df8ea --- /dev/null +++ b/docs/components/468-oauth-buttons.md @@ -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 选择。 + +## 限制 + +- 登录流程由调用方完成。 diff --git a/docs/components/469-two-factor-input.md b/docs/components/469-two-factor-input.md new file mode 100644 index 0000000..4c0db44 --- /dev/null +++ b/docs/components/469-two-factor-input.md @@ -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 到重新发送按钮。 + +## 限制 + +- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。 +- 倒计时每秒刷新一次,结束后不再重绘。 diff --git a/docs/components/470-profile-editor.md b/docs/components/470-profile-editor.md new file mode 100644 index 0000000..f29d295 --- /dev/null +++ b/docs/components/470-profile-editor.md @@ -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。 diff --git a/docs/components/471-account-switcher.md b/docs/components/471-account-switcher.md new file mode 100644 index 0000000..a680d88 --- /dev/null +++ b/docs/components/471-account-switcher.md @@ -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 关闭。 + +## 限制 + +大集合使用虚拟列表,仅构建当前可见行。 + +- 菜单项只显示文字,头像只在按钮上。 diff --git a/docs/components/472-workspace-switcher.md b/docs/components/472-workspace-switcher.md new file mode 100644 index 0000000..f7bbb97 --- /dev/null +++ b/docs/components/472-workspace-switcher.md @@ -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 关闭。 + +## 限制 + +大集合使用虚拟列表,构建当前可见行;搜索与分组在数据或查询变化时更新。 + +- 无。 diff --git a/docs/components/473-user-menu.md b/docs/components/473-user-menu.md new file mode 100644 index 0000000..8737e75 --- /dev/null +++ b/docs/components/473-user-menu.md @@ -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 关闭。 + +## 限制 + +- 按钮名称包含用户名与状态。 diff --git a/docs/components/474-session-list.md b/docs/components/474-session-list.md new file mode 100644 index 0000000..dc7769b --- /dev/null +++ b/docs/components/474-session-list.md @@ -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 到各注销按钮;确认框默认聚焦取消。 + +## 限制 + +大集合使用虚拟列表,仅构建当前可见行。 + +- 注销由调用方执行。 diff --git a/docs/components/475-api-key-manager.md b/docs/components/475-api-key-manager.md new file mode 100644 index 0000000..7593dbc --- /dev/null +++ b/docs/components/475-api-key-manager.md @@ -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 创建;复制按钮写入剪贴板并播报“已复制”。 + +## 限制 + +大集合使用虚拟列表,仅构建当前可见行。 + +- 验证码与密钥通过调用方状态接入;组件事件与日志仅包含完成或失败标记。 +- 明文作为可选择文字显示一次,屏幕阅读器可读出文字本身。 +- 创建进行中等待调用方结果;禁用关闭全部弹层并清除已显示明文。 +- 待处理请求跨禁用保留;失败后恢复名称与错误,支持重试。 diff --git a/docs/components/476-usage-quota.md b/docs/components/476-usage-quota.md new file mode 100644 index 0000000..49b33cf --- /dev/null +++ b/docs/components/476-usage-quota.md @@ -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 | 达到该比例即提示接近上限。 | + +## 状态 + +正常、接近上限、已达上限。 + +## 事件 + +无。 + +## 键盘操作 + +不获取焦点;每项以计量角色读出。 + +## 限制 + +- 无。 diff --git a/docs/components/477-onboarding-wizard.md b/docs/components/477-onboarding-wizard.md new file mode 100644 index 0000000..562cf5b --- /dev/null +++ b/docs/components/477-onboarding-wizard.md @@ -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 在按钮与内容间移动。 + +## 限制 + +- 无。 diff --git a/docs/components/478-coachmark.md b/docs/components/478-coachmark.md new file mode 100644 index 0000000..2241266 --- /dev/null +++ b/docs/components/478-coachmark.md @@ -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 定位,空间不足时翻转到锚点上方。 diff --git a/docs/components/479-whats-new-dialog.md b/docs/components/479-whats-new-dialog.md new file mode 100644 index 0000000..e957c31 --- /dev/null +++ b/docs/components/479-whats-new-dialog.md @@ -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 关闭。 + +## 限制 + +- 无。 diff --git a/docs/components/480-feedback-widget.md b/docs/components/480-feedback-widget.md new file mode 100644 index 0000000..9b89cb1 --- /dev/null +++ b/docs/components/480-feedback-widget.md @@ -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 到内容与按钮。 + +## 限制 + +- 截图采集由调用方完成。 diff --git a/example_account_test.go b/example_account_test.go new file mode 100644 index 0000000..cc29618 --- /dev/null +++ b/example_account_test.go @@ -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) +} diff --git a/examples/gallery/pages_account.go b/examples/gallery/pages_account.go index 022f3f7..7a27e3c 100644 --- a/examples/gallery/pages_account.go +++ b/examples/gallery/pages_account.go @@ -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)) + } + } +} diff --git a/examples/gallery/pages_account_auth.go b/examples/gallery/pages_account_auth.go new file mode 100644 index 0000000..47dc01b --- /dev/null +++ b/examples/gallery/pages_account_auth.go @@ -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") + } +} diff --git a/examples/gallery/pages_account_code.go b/examples/gallery/pages_account_code.go new file mode 100644 index 0000000..70d4a92 --- /dev/null +++ b/examples/gallery/pages_account_code.go @@ -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)`, +} diff --git a/examples/gallery/pages_account_more.go b/examples/gallery/pages_account_more.go new file mode 100644 index 0000000..210f07d --- /dev/null +++ b/examples/gallery/pages_account_more.go @@ -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() + }() + } +} diff --git a/examples/gallery/zz_account_test.go b/examples/gallery/zz_account_test.go new file mode 100644 index 0000000..e986718 --- /dev/null +++ b/examples/gallery/zz_account_test.go @@ -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") + } + }) + } +} diff --git a/feedback_widget.go b/feedback_widget.go new file mode 100644 index 0000000..8002399 --- /dev/null +++ b/feedback_widget.go @@ -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 +} diff --git a/feedback_widget_test.go b/feedback_widget_test.go new file mode 100644 index 0000000..f40e405 --- /dev/null +++ b/feedback_widget_test.go @@ -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") + } +} diff --git a/keyboard_shortcuts_list.go b/keyboard_shortcuts_list.go new file mode 100644 index 0000000..4b7fa70 --- /dev/null +++ b/keyboard_shortcuts_list.go @@ -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 +} diff --git a/keyboard_shortcuts_list_test.go b/keyboard_shortcuts_list_test.go new file mode 100644 index 0000000..22b1611 --- /dev/null +++ b/keyboard_shortcuts_list_test.go @@ -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") + } +} diff --git a/login_form.go b/login_form.go new file mode 100644 index 0000000..ddd3470 --- /dev/null +++ b/login_form.go @@ -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 +} diff --git a/login_form_test.go b/login_form_test.go new file mode 100644 index 0000000..a014e16 --- /dev/null +++ b/login_form_test.go @@ -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) {}}) + }) +} diff --git a/messages_account.go b/messages_account.go new file mode 100644 index 0000000..b0884d0 --- /dev/null +++ b/messages_account.go @@ -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", "清空"}, + }) +} diff --git a/oauth_buttons.go b/oauth_buttons.go new file mode 100644 index 0000000..b13b08e --- /dev/null +++ b/oauth_buttons.go @@ -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 +} diff --git a/oauth_buttons_test.go b/oauth_buttons_test.go new file mode 100644 index 0000000..5d7f0e5 --- /dev/null +++ b/oauth_buttons_test.go @@ -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) + } +} diff --git a/onboarding_wizard.go b/onboarding_wizard.go new file mode 100644 index 0000000..98ee3e7 --- /dev/null +++ b/onboarding_wizard.go @@ -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 +} diff --git a/onboarding_wizard_test.go b/onboarding_wizard_test.go new file mode 100644 index 0000000..84ca0da --- /dev/null +++ b/onboarding_wizard_test.go @@ -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{}) + }) +} diff --git a/overlay_layers.go b/overlay_layers.go index 6b9c419..727db55 100644 --- a/overlay_layers.go +++ b/overlay_layers.go @@ -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. diff --git a/overlay_test.go b/overlay_test.go index f7ad203..95394fb 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -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 { diff --git a/profile_editor.go b/profile_editor.go new file mode 100644 index 0000000..52a0caf --- /dev/null +++ b/profile_editor.go @@ -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 +} diff --git a/profile_editor_test.go b/profile_editor_test.go new file mode 100644 index 0000000..5188900 --- /dev/null +++ b/profile_editor_test.go @@ -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}) }) +} diff --git a/session_list.go b/session_list.go new file mode 100644 index 0000000..0dbedbe --- /dev/null +++ b/session_list.go @@ -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 +} diff --git a/session_list_test.go b/session_list_test.go new file mode 100644 index 0000000..569ab41 --- /dev/null +++ b/session_list_test.go @@ -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) + } +} diff --git a/settings_layout.go b/settings_layout.go new file mode 100644 index 0000000..a525c60 --- /dev/null +++ b/settings_layout.go @@ -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 +} diff --git a/settings_layout_test.go b/settings_layout_test.go new file mode 100644 index 0000000..44699ef --- /dev/null +++ b/settings_layout_test.go @@ -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") + } +} diff --git a/settings_search.go b/settings_search.go new file mode 100644 index 0000000..5e0cf97 --- /dev/null +++ b/settings_search.go @@ -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 +} diff --git a/settings_search_test.go b/settings_search_test.go new file mode 100644 index 0000000..81ef93b --- /dev/null +++ b/settings_search_test.go @@ -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}) }) +} diff --git a/shortcut_recorder.go b/shortcut_recorder.go new file mode 100644 index 0000000..65c4ad2 --- /dev/null +++ b/shortcut_recorder.go @@ -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)) + }) +} diff --git a/shortcut_recorder_test.go b/shortcut_recorder_test.go new file mode 100644 index 0000000..1d99aad --- /dev/null +++ b/shortcut_recorder_test.go @@ -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") + } + }) + } +} diff --git a/testdata/snapshots/darwin/accent_color_picker-dark.png b/testdata/snapshots/darwin/accent_color_picker-dark.png new file mode 100644 index 0000000..99cff8e Binary files /dev/null and b/testdata/snapshots/darwin/accent_color_picker-dark.png differ diff --git a/testdata/snapshots/darwin/accent_color_picker-light.png b/testdata/snapshots/darwin/accent_color_picker-light.png new file mode 100644 index 0000000..7123590 Binary files /dev/null and b/testdata/snapshots/darwin/accent_color_picker-light.png differ diff --git a/testdata/snapshots/darwin/account_switcher-dark.png b/testdata/snapshots/darwin/account_switcher-dark.png new file mode 100644 index 0000000..359d82f Binary files /dev/null and b/testdata/snapshots/darwin/account_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/account_switcher-light.png b/testdata/snapshots/darwin/account_switcher-light.png new file mode 100644 index 0000000..75c9ae7 Binary files /dev/null and b/testdata/snapshots/darwin/account_switcher-light.png differ diff --git a/testdata/snapshots/darwin/api_key_manager-dark.png b/testdata/snapshots/darwin/api_key_manager-dark.png new file mode 100644 index 0000000..dfa634b Binary files /dev/null and b/testdata/snapshots/darwin/api_key_manager-dark.png differ diff --git a/testdata/snapshots/darwin/api_key_manager-light.png b/testdata/snapshots/darwin/api_key_manager-light.png new file mode 100644 index 0000000..6943480 Binary files /dev/null and b/testdata/snapshots/darwin/api_key_manager-light.png differ diff --git a/testdata/snapshots/darwin/coachmark-dark.png b/testdata/snapshots/darwin/coachmark-dark.png new file mode 100644 index 0000000..f0ea33d Binary files /dev/null and b/testdata/snapshots/darwin/coachmark-dark.png differ diff --git a/testdata/snapshots/darwin/coachmark-light.png b/testdata/snapshots/darwin/coachmark-light.png new file mode 100644 index 0000000..ff58397 Binary files /dev/null and b/testdata/snapshots/darwin/coachmark-light.png differ diff --git a/testdata/snapshots/darwin/feedback_widget-dark.png b/testdata/snapshots/darwin/feedback_widget-dark.png new file mode 100644 index 0000000..d4a40d8 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_widget-dark.png differ diff --git a/testdata/snapshots/darwin/feedback_widget-light.png b/testdata/snapshots/darwin/feedback_widget-light.png new file mode 100644 index 0000000..91989d7 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_widget-light.png differ diff --git a/testdata/snapshots/darwin/keyboard_shortcuts_list-dark.png b/testdata/snapshots/darwin/keyboard_shortcuts_list-dark.png new file mode 100644 index 0000000..bc52177 Binary files /dev/null and b/testdata/snapshots/darwin/keyboard_shortcuts_list-dark.png differ diff --git a/testdata/snapshots/darwin/keyboard_shortcuts_list-light.png b/testdata/snapshots/darwin/keyboard_shortcuts_list-light.png new file mode 100644 index 0000000..ebc4054 Binary files /dev/null and b/testdata/snapshots/darwin/keyboard_shortcuts_list-light.png differ diff --git a/testdata/snapshots/darwin/login_form-dark.png b/testdata/snapshots/darwin/login_form-dark.png new file mode 100644 index 0000000..00ea569 Binary files /dev/null and b/testdata/snapshots/darwin/login_form-dark.png differ diff --git a/testdata/snapshots/darwin/login_form-light.png b/testdata/snapshots/darwin/login_form-light.png new file mode 100644 index 0000000..cfbdefa Binary files /dev/null and b/testdata/snapshots/darwin/login_form-light.png differ diff --git a/testdata/snapshots/darwin/oauth_buttons-dark.png b/testdata/snapshots/darwin/oauth_buttons-dark.png new file mode 100644 index 0000000..e59a387 Binary files /dev/null and b/testdata/snapshots/darwin/oauth_buttons-dark.png differ diff --git a/testdata/snapshots/darwin/oauth_buttons-light.png b/testdata/snapshots/darwin/oauth_buttons-light.png new file mode 100644 index 0000000..b2424c2 Binary files /dev/null and b/testdata/snapshots/darwin/oauth_buttons-light.png differ diff --git a/testdata/snapshots/darwin/onboarding_wizard-dark.png b/testdata/snapshots/darwin/onboarding_wizard-dark.png new file mode 100644 index 0000000..9569f45 Binary files /dev/null and b/testdata/snapshots/darwin/onboarding_wizard-dark.png differ diff --git a/testdata/snapshots/darwin/onboarding_wizard-light.png b/testdata/snapshots/darwin/onboarding_wizard-light.png new file mode 100644 index 0000000..e882a90 Binary files /dev/null and b/testdata/snapshots/darwin/onboarding_wizard-light.png differ diff --git a/testdata/snapshots/darwin/profile_editor-dark.png b/testdata/snapshots/darwin/profile_editor-dark.png new file mode 100644 index 0000000..0eafde2 Binary files /dev/null and b/testdata/snapshots/darwin/profile_editor-dark.png differ diff --git a/testdata/snapshots/darwin/profile_editor-light.png b/testdata/snapshots/darwin/profile_editor-light.png new file mode 100644 index 0000000..1abd30a Binary files /dev/null and b/testdata/snapshots/darwin/profile_editor-light.png differ diff --git a/testdata/snapshots/darwin/session_list-dark.png b/testdata/snapshots/darwin/session_list-dark.png new file mode 100644 index 0000000..919b725 Binary files /dev/null and b/testdata/snapshots/darwin/session_list-dark.png differ diff --git a/testdata/snapshots/darwin/session_list-light.png b/testdata/snapshots/darwin/session_list-light.png new file mode 100644 index 0000000..2b65923 Binary files /dev/null and b/testdata/snapshots/darwin/session_list-light.png differ diff --git a/testdata/snapshots/darwin/settings_layout-dark.png b/testdata/snapshots/darwin/settings_layout-dark.png new file mode 100644 index 0000000..c9483d1 Binary files /dev/null and b/testdata/snapshots/darwin/settings_layout-dark.png differ diff --git a/testdata/snapshots/darwin/settings_layout-light.png b/testdata/snapshots/darwin/settings_layout-light.png new file mode 100644 index 0000000..6ccf984 Binary files /dev/null and b/testdata/snapshots/darwin/settings_layout-light.png differ diff --git a/testdata/snapshots/darwin/settings_search-dark.png b/testdata/snapshots/darwin/settings_search-dark.png new file mode 100644 index 0000000..d078c2c Binary files /dev/null and b/testdata/snapshots/darwin/settings_search-dark.png differ diff --git a/testdata/snapshots/darwin/settings_search-light.png b/testdata/snapshots/darwin/settings_search-light.png new file mode 100644 index 0000000..74f9fb8 Binary files /dev/null and b/testdata/snapshots/darwin/settings_search-light.png differ diff --git a/testdata/snapshots/darwin/shortcut_recorder-dark.png b/testdata/snapshots/darwin/shortcut_recorder-dark.png new file mode 100644 index 0000000..a7592ce Binary files /dev/null and b/testdata/snapshots/darwin/shortcut_recorder-dark.png differ diff --git a/testdata/snapshots/darwin/shortcut_recorder-light.png b/testdata/snapshots/darwin/shortcut_recorder-light.png new file mode 100644 index 0000000..461cac2 Binary files /dev/null and b/testdata/snapshots/darwin/shortcut_recorder-light.png differ diff --git a/testdata/snapshots/darwin/theme_selector-dark.png b/testdata/snapshots/darwin/theme_selector-dark.png new file mode 100644 index 0000000..792029b Binary files /dev/null and b/testdata/snapshots/darwin/theme_selector-dark.png differ diff --git a/testdata/snapshots/darwin/theme_selector-light.png b/testdata/snapshots/darwin/theme_selector-light.png new file mode 100644 index 0000000..4ce1ef9 Binary files /dev/null and b/testdata/snapshots/darwin/theme_selector-light.png differ diff --git a/testdata/snapshots/darwin/two_factor_input-dark.png b/testdata/snapshots/darwin/two_factor_input-dark.png new file mode 100644 index 0000000..fb1b1b8 Binary files /dev/null and b/testdata/snapshots/darwin/two_factor_input-dark.png differ diff --git a/testdata/snapshots/darwin/two_factor_input-light.png b/testdata/snapshots/darwin/two_factor_input-light.png new file mode 100644 index 0000000..95eab17 Binary files /dev/null and b/testdata/snapshots/darwin/two_factor_input-light.png differ diff --git a/testdata/snapshots/darwin/usage_quota-dark.png b/testdata/snapshots/darwin/usage_quota-dark.png new file mode 100644 index 0000000..70396b1 Binary files /dev/null and b/testdata/snapshots/darwin/usage_quota-dark.png differ diff --git a/testdata/snapshots/darwin/usage_quota-light.png b/testdata/snapshots/darwin/usage_quota-light.png new file mode 100644 index 0000000..72d3962 Binary files /dev/null and b/testdata/snapshots/darwin/usage_quota-light.png differ diff --git a/testdata/snapshots/darwin/user_menu-dark.png b/testdata/snapshots/darwin/user_menu-dark.png new file mode 100644 index 0000000..f4426d6 Binary files /dev/null and b/testdata/snapshots/darwin/user_menu-dark.png differ diff --git a/testdata/snapshots/darwin/user_menu-light.png b/testdata/snapshots/darwin/user_menu-light.png new file mode 100644 index 0000000..1f1644e Binary files /dev/null and b/testdata/snapshots/darwin/user_menu-light.png differ diff --git a/testdata/snapshots/darwin/whats_new_dialog-dark.png b/testdata/snapshots/darwin/whats_new_dialog-dark.png new file mode 100644 index 0000000..f0f92ce Binary files /dev/null and b/testdata/snapshots/darwin/whats_new_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/whats_new_dialog-light.png b/testdata/snapshots/darwin/whats_new_dialog-light.png new file mode 100644 index 0000000..483f03a Binary files /dev/null and b/testdata/snapshots/darwin/whats_new_dialog-light.png differ diff --git a/testdata/snapshots/darwin/workspace_switcher-dark.png b/testdata/snapshots/darwin/workspace_switcher-dark.png new file mode 100644 index 0000000..ecb1425 Binary files /dev/null and b/testdata/snapshots/darwin/workspace_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/workspace_switcher-light.png b/testdata/snapshots/darwin/workspace_switcher-light.png new file mode 100644 index 0000000..c8cdcce Binary files /dev/null and b/testdata/snapshots/darwin/workspace_switcher-light.png differ diff --git a/theme_selector.go b/theme_selector.go new file mode 100644 index 0000000..62d537d --- /dev/null +++ b/theme_selector.go @@ -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) +} diff --git a/theme_selector_test.go b/theme_selector_test.go new file mode 100644 index 0000000..5906fad --- /dev/null +++ b/theme_selector_test.go @@ -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{}) }) + }) +} diff --git a/two_factor_input.go b/two_factor_input.go new file mode 100644 index 0000000..3e3282d --- /dev/null +++ b/two_factor_input.go @@ -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 +} diff --git a/two_factor_input_test.go b/two_factor_input_test.go new file mode 100644 index 0000000..5bf5aeb --- /dev/null +++ b/two_factor_input_test.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestTwoFactorInputPasteCompleteAndResend(t *testing.T) { + logs := accountLogs(t) + code, done, resent := "", 0, 0 + opts := TwoFactorInputOptions{ResendAt: time.Now().Add(-time.Second)} + tt := accountTester(420, 220, func(c *ui.Context) { + r := TwoFactorInput(c, &code, opts) + if r.Completed() { + done++ + } + if r.Resend() { + resent++ + } + }) + tt.Key(0, ui.KeyTab) + tt.SetClipboard("381 942") + tt.Command("paste") + if code != "381942" || done != 1 { + t.Fatalf("paste: %d digits, %d completions", len(code), done) + } + if strings.Contains(logs.String(), "381942") { + t.Fatal("the code reached the log") + } + if err := tt.Click("Resend code"); err != nil { + t.Fatal(err) + } + if resent != 1 { + t.Fatal("resend not reported") + } + opts.ResendAt = time.Now().Add(65 * time.Second) + opts.Error = "That code has expired." + tt.Frame() + if !tt.HasText("Resend in 1:05") && !tt.HasText("Resend in 1:04") || !tt.HasText("That code has expired.") { + t.Fatalf("countdown: %q", tt.Texts()) + } + _ = tt.Click("Resend in 1:05") + if resent != 1 { + t.Fatal("resent during countdown") + } +} + +func TestTwoFactorInputDisabled(t *testing.T) { + code := "12" + tt := accountTester(420, 220, func(c *ui.Context) { + TwoFactorInput(c, &code, TwoFactorInputOptions{Disabled: true}) + }) + tt.Key(0, ui.KeyTab) + tt.Type("3456") + if code != "12" { + t.Fatal("disabled input changed") + } +} + +func TestTwoFactorInputSnapshot(t *testing.T) { + snapshot(t, "two_factor_input", 420, 160, func(c *ui.Context) { + code := ui.Local(c.Root(), "code", func() string { return "3819" }) + ui.Column(c).Padding(16).Children(func() { + TwoFactorInput(c, code, TwoFactorInputOptions{ResendAt: c.Now().Add(42 * time.Second)}) + }) + }) +} + +func TestTwoFactorInputCountdownRoundsUp(t *testing.T) { + tt := accountTester(420, 220, func(c *ui.Context) { + code := "" + TwoFactorInput(c, &code, TwoFactorInputOptions{ResendAt: c.Now().Add(100 * time.Millisecond)}) + }) + if !tt.HasText("Resend in 0:01") { + t.Fatal("positive wait must show one remaining second") + } +} + +func TestTwoFactorInputInvalidValueKeepsSecretOutOfPanic(t *testing.T) { + const secret = "1234567" + defer func() { + v := recover() + if v == nil { + t.Fatal("oversized code accepted") + } + if strings.Contains(fmt.Sprint(v), secret) { + t.Fatal("code appears in panic") + } + }() + code := secret + accountTester(420, 220, func(c *ui.Context) { TwoFactorInput(c, &code, TwoFactorInputOptions{}) }) +} diff --git a/usage_quota.go b/usage_quota.go new file mode 100644 index 0000000..64d33ae --- /dev/null +++ b/usage_quota.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// QuotaItem is one metered resource: Used of Limit, in Unit. +type QuotaItem struct { + Label string + Used, Limit float64 + Unit string +} + +// UsageQuotaOptions configure a UsageQuota. +type UsageQuotaOptions struct { + // ResetAt provides the reset timestamp; zero hides it. + ResetAt time.Time + // Warn sets the warning threshold; zero means 0.8. + Warn float64 +} + +// UsageQuota displays usage bars and reset time. +func UsageQuota(c *ui.Context, items []QuotaItem, opts UsageQuotaOptions) *ui.Element { + warn := opts.Warn + if warn == 0 { + warn = 0.8 + } + if warn < 0 || warn > 1 || math.IsNaN(warn) { + panic("mujica: UsageQuota Warn must be between 0 and 1") + } + k := Tokens(c) + d := Density(c) + e := ui.Column(c).Gap(d.Unit() * 4).MinWidth(320) + e.Children(func() { + for i, it := range items { + if it.Label == "" || !(it.Limit > 0) || it.Used < 0 || math.IsNaN(it.Used) || math.IsInf(it.Used, 0) || math.IsInf(it.Limit, 0) { + panic(fmt.Sprintf("mujica: QuotaItem %q needs a Label, a positive Limit and Used of zero or more", it.Label)) + } + share := it.Used / it.Limit + col, icon, note := k.AccentText, "", "" + switch { + case share >= 1: + col, icon, note = k.Danger, "circle-x", msg(c, "usage_quota.over") + case share >= warn: + col, icon, note = k.Warning, "triangle-alert", msg(c, "usage_quota.near") + } + amount := fmt.Sprintf(msg(c, "usage_quota.amount"), statisticFormat(it.Used, 0), statisticFormat(it.Limit, 0), it.Unit) + ui.Column(c).Key(i).Gap(d.Unit()).Role(ui.RoleMeter).Label(it.Label). + Description(fmt.Sprintf("%s, %s%% %s", amount, statisticFormat(share*100, 0), note)).Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, it.Label).FontWeight(500).Grow(1) + if icon != "" { + ui.Row(c).Gap(d.Unit() / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(12, 12) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600) + }) + } + ui.Text(c, amount).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum") + }) + ui.Box(c).Height(6).FillWidth().Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.SurfaceHover, theme.ControlRadius) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(math.Min(1, share)), H: r.H}, col, theme.ControlRadius) + }) + }) + } + if !opts.ResetAt.IsZero() { + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() { + ui.Icon(c, icons.Must("clock")).Size(12, 12) + ui.Text(c, usageQuotaReset(c, opts.ResetAt)).FontSize(FontSize(c, theme.CaptionSize)) + }) + } + }) + return e +} + +// usageQuotaReset formats hours or the reset date. +func usageQuotaReset(c *ui.Context, at time.Time) string { + left := at.Sub(c.Now()) + switch { + case left <= 0: + return msg(c, "usage_quota.reset_due") + case left < 24*time.Hour: + return fmt.Sprintf(msg(c, "usage_quota.reset_hours"), int(math.Ceil(left.Hours()))) + } + return fmt.Sprintf(msg(c, "usage_quota.reset_on"), at.Format("2006-01-02")) +} diff --git a/usage_quota_test.go b/usage_quota_test.go new file mode 100644 index 0000000..503d2ff --- /dev/null +++ b/usage_quota_test.go @@ -0,0 +1,60 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var quotaItems = []QuotaItem{{Label: "API calls", Used: 4200, Limit: 10000}, {Label: "Storage", Used: 9, Limit: 10, Unit: "GB"}, {Label: "Seats", Used: 12, Limit: 10}} + +func TestUsageQuotaLevelsAndReset(t *testing.T) { + reset := time.Duration(5 * time.Hour) + tt := accountTester(500, 400, func(c *ui.Context) { + UsageQuota(c, quotaItems, UsageQuotaOptions{ResetAt: c.Now().Add(reset)}) + }) + for _, want := range []string{"4,200 of 10,000 ", "9 of 10 GB", "Near limit", "Limit reached", "Resets in 5 h"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + reset = 72 * time.Hour + tt.Frame() + if tt.HasText("Resets in 5 h") { + t.Fatalf("far reset: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("zero limit accepted") + } + }() + accountTester(100, 100, func(c *ui.Context) { UsageQuota(c, []QuotaItem{{Label: "x"}}, UsageQuotaOptions{}) }) +} + +func TestUsageQuotaSnapshot(t *testing.T) { + snapshot(t, "usage_quota", 440, 230, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + UsageQuota(c, quotaItems, UsageQuotaOptions{ResetAt: c.Now().Add(5*time.Hour + time.Minute)}) + }) + }) +} + +func TestUsageQuotaRejectsNonFiniteValues(t *testing.T) { + for _, tc := range []struct{ used, limit, warn float64 }{ + {1, 10, math.NaN()}, {math.Inf(1), 10, 0}, {1, math.Inf(1), 0}, {math.NaN(), 10, 0}, {1, math.NaN(), 0}, + } { + t.Run(fmt.Sprint(tc), func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("non-finite quota accepted") + } + }() + accountTester(500, 300, func(c *ui.Context) { + UsageQuota(c, []QuotaItem{{Label: "Calls", Used: tc.used, Limit: tc.limit}}, UsageQuotaOptions{Warn: tc.warn}) + }) + }) + } +} diff --git a/user_menu.go b/user_menu.go new file mode 100644 index 0000000..0bef3bb --- /dev/null +++ b/user_menu.go @@ -0,0 +1,110 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// UserStatus is the presence a user shows to others. +type UserStatus int + +const ( + PresenceOnline UserStatus = iota + PresenceAway + PresenceBusy + PresenceInvisible +) + +// UserMenuOptions configure a UserMenu. Name is required. +type UserMenuOptions struct { + Name, Email string + Avatar *ui.Bitmap + Disabled bool +} + +// UserMenuResult reports presence, settings and sign-out events. +type UserMenuResult struct { + Element *ui.Element + changed, settings, leave bool +} + +// StatusChanged reports a new presence in *status. +func (r UserMenuResult) StatusChanged() bool { return r.changed } + +// Settings reports a request to open settings. +func (r UserMenuResult) Settings() bool { return r.settings } + +// SignOut reports a request to sign out. +func (r UserMenuResult) SignOut() bool { return r.leave } + +// UserMenu opens presence, settings and sign-out actions. +func UserMenu(c *ui.Context, status *UserStatus, opts UserMenuOptions) UserMenuResult { + if opts.Name == "" { + panic("mujica: UserMenu needs a Name") + } + if *status < PresenceOnline || *status > PresenceInvisible { + panic(fmt.Sprintf("mujica: UserMenu status %d is unknown", *status)) + } + k := Tokens(c) + var r UserMenuResult + word := msg(c, fmt.Sprintf("user_menu.status%d", *status)) + b := ui.ButtonBase(c).Role(ui.RoleMenuButton).Size(36, 36).Radius(18).Disabled(opts.Disabled). + Label(fmt.Sprintf(msg(c, "user_menu.label"), opts.Name, word)) + r.Element = b + internal.Ring(b, k.Focus, 18) + open := ui.Local(b, "open", func() bool { return false }) + disabled := b.IsDisabled() + if disabled { + *open = false + } else if b.Clicked() || b.Shortcut(0, ui.KeyDown) { + *open = true + } + st := *status + b.Children(func() { Avatar(c, opts.Name, AvatarOptions{Image: opts.Avatar, Size: 32}) }) + b.DrawOver(func(p *ui.Painter, rect ui.Rect) { userMenuDot(p, k, rect.X+rect.W-6, rect.Y+rect.H-6, st) }) + popupMenuOpen(c, b, open, func(m *PopupMenu) { + head := opts.Name + if opts.Email != "" { + head += " · " + opts.Email + } + m.Group(head, func() { + for s := PresenceOnline; s <= PresenceInvisible; s++ { + on := s == *status + if m.Check(msg(c, fmt.Sprintf("user_menu.status%d", s)), &on, PopupMenuItemOptions{}) && s != *status && !disabled { + *status, r.changed = s, true + slog.Debug("mujica: user_menu status", "status", s) + } + } + }) + m.Separator() + r.settings = m.Item(msg(c, "user_menu.settings"), PopupMenuItemOptions{}) && !disabled + r.leave = m.Item(msg(c, "user_menu.sign_out"), PopupMenuItemOptions{}) && !disabled + }) + if r.settings || r.leave { + slog.Debug("mujica: user_menu chose", "settings", r.settings, "sign_out", r.leave) + } + return r +} + +// userMenuDot distinguishes presence using shapes and colors. +func userMenuDot(p *ui.Painter, k theme.Tokens, cx, cy float32, s UserStatus) { + const d = 11 + r := ui.Rect{X: cx - d/2, Y: cy - d/2, W: d, H: d} + p.Fill(ui.Rect{X: r.X - 2, Y: r.Y - 2, W: d + 4, H: d + 4}, k.Surface, d) + switch s { + case PresenceOnline: + p.Fill(r, k.Success, d) + case PresenceAway: + p.Stroke(r, k.Warning, d, 3) + case PresenceBusy: + p.Fill(r, k.Danger, d) + p.Fill(ui.Rect{X: r.X + 2.5, Y: cy - 1, W: d - 5, H: 2}, k.Surface, 0) + default: + p.Stroke(r, k.TextMuted, d, 1.5) + } +} diff --git a/user_menu_test.go b/user_menu_test.go new file mode 100644 index 0000000..71505f6 --- /dev/null +++ b/user_menu_test.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestUserMenuStatusSettingsSignOut(t *testing.T) { + status := PresenceOnline + var changes, settings, leaves int + tt := accountTester(500, 400, func(c *ui.Context) { + r := UserMenu(c, &status, UserMenuOptions{Name: "Sakiko Togawa", Email: "sakiko@court"}) + if r.StatusChanged() { + changes++ + } + if r.Settings() { + settings++ + } + if r.SignOut() { + leaves++ + } + }) + if err := tt.Click("Sakiko Togawa, Online"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Sakiko Togawa · sakiko@court") { + t.Fatalf("header: %q", tt.Texts()) + } + _ = tt.Click("Do not disturb") + if status != PresenceBusy || changes != 1 { + t.Fatalf("status %v", status) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + _ = tt.Click("Settings") + _ = tt.Click("Sakiko Togawa, Do not disturb") + _ = tt.Click("Sign out") + if settings != 1 || leaves != 1 { + t.Fatalf("settings %d sign out %d", settings, leaves) + } +} + +func TestUserMenuSnapshot(t *testing.T) { + snapshot(t, "user_menu", 220, 90, func(c *ui.Context) { + ui.Row(c).Padding(20).Gap(16).Children(func() { + for s := PresenceOnline; s <= PresenceInvisible; s++ { + st := s + ui.Box(c).Key(int(s)).Children(func() { UserMenu(c, &st, UserMenuOptions{Name: "Sakiko Togawa"}) }) + } + }) + }) +} + +func TestUserMenuDisabledAndInvalidStatus(t *testing.T) { + status, disabled, actions := PresenceOnline, false, 0 + tt := accountTester(600, 500, func(c *ui.Context) { + ui.Column(c).Disabled(disabled).Children(func() { + r := UserMenu(c, &status, UserMenuOptions{Name: "User"}) + if r.StatusChanged() || r.Settings() || r.SignOut() { + actions++ + } + }) + }) + if err := tt.Click("User, Online"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if status != PresenceOnline || actions != 0 || tt.HasText("Sign out") { + t.Fatal("disabled user menu acts") + } + status = UserStatus(99) + accountPanics(t, func(c *ui.Context) { UserMenu(c, &status, UserMenuOptions{Name: "User"}) }) +} diff --git a/whats_new_dialog.go b/whats_new_dialog.go new file mode 100644 index 0000000..e83e96d --- /dev/null +++ b/whats_new_dialog.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ChangeKind tags a release note. +type ChangeKind int + +const ( + ChangeNew ChangeKind = iota + ChangeImproved + ChangeFixed +) + +// ReleaseChange is one line of release notes. +type ReleaseChange struct { + Kind ChangeKind + Text string +} + +// Release is a version's notes. +type Release struct { + Version, Date string + Changes []ReleaseChange +} + +// WhatsNewDialogOptions configure a WhatsNewDialog. +type WhatsNewDialogOptions struct { + // Title replaces the built-in "What's new". + Title string +} + +// WhatsNewDialog displays release notes and a persistent hide choice. +func WhatsNewDialog(c *ui.Context, open *bool, hide *bool, releases []Release, opts WhatsNewDialogOptions) ChoiceResult { + if len(releases) == 0 { + panic("mujica: WhatsNewDialog needs releases") + } + for _, rel := range releases { + if rel.Version == "" { + panic("mujica: Release needs a Version") + } + for _, change := range rel.Changes { + if change.Kind < ChangeNew || change.Kind > ChangeFixed || change.Text == "" { + panic("mujica: ReleaseChange needs a valid Kind and Text") + } + } + } + if ui.Box(c).IsDisabled() { + *open = false + } + k := Tokens(c) + d := Density(c) + title := opts.Title + if title == "" { + title = msg(c, "whats_new_dialog.title") + } + var r ChoiceResult + was := *open + before := *hide + r.Element = Dialog(c, open, DialogOptions{Title: title, Width: 480, Decorated: true, Actions: func() { + Checkbox(c, hide, msg(c, "whats_new_dialog.hide"), CheckboxOptions{}) + ui.Box(c).Grow(1) + if Button(c, msg(c, "whats_new_dialog.close"), ButtonOptions{}).AutoFocus().Clicked() { + *open = false + } + }}, func() { + for i, rel := range releases { + if rel.Version == "" { + panic("mujica: Release needs a Version") + } + ui.Column(c).Key(i).Gap(d.Unit() * 2).Role(ui.RoleGroup).Label(rel.Version).Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.End).Children(func() { + ui.Text(c, rel.Version).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600) + if rel.Date != "" { + ui.Text(c, rel.Date).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + } + }) + for _, ch := range rel.Changes { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() { + tone := map[ChangeKind]BadgeTone{ChangeNew: BadgeAccent, ChangeImproved: BadgeInfo, ChangeFixed: BadgeSuccess}[ch.Kind] + Badge(c, BadgeOptions{Text: msg(c, fmt.Sprintf("whats_new_dialog.kind%d", ch.Kind)), Tone: tone}, nil).Shrink(0) + ui.Text(c, ch.Text).Grow(1).MinWidth(0) + }) + } + }) + } + }) + r.changed = *hide != before + if was && !*open { + slog.Debug("mujica: whats_new_dialog close", "hide", *hide) + } + return r +} diff --git a/whats_new_dialog_test.go b/whats_new_dialog_test.go new file mode 100644 index 0000000..dc716a6 --- /dev/null +++ b/whats_new_dialog_test.go @@ -0,0 +1,67 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var releases = []Release{ + {Version: "1.1", Date: "2026-10-05", Changes: []ReleaseChange{{ChangeNew, "500 components."}, {ChangeFixed, "Snapshots no longer drift."}}}, + {Version: "1.0", Changes: []ReleaseChange{{ChangeImproved, "Court-gothic theme."}}}, +} + +func TestWhatsNewDialogListsAndHides(t *testing.T) { + open, hide, n := true, false, 0 + tt := accountTester(700, 600, func(c *ui.Context) { + if WhatsNewDialog(c, &open, &hide, releases, WhatsNewDialogOptions{}).Changed() { + n++ + } + }) + for _, want := range []string{"What's new", "1.1", "2026-10-05", "New", "Fixed", "Improved", "Court-gothic theme."} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } + _ = tt.Click("Don't show again") + if !hide || n != 1 { + t.Fatal("hide not set") + } + _ = tt.Click("Got it") + if open { + t.Fatal("not closed") + } + open = true + tt.Frame() + tt.Key(0, ui.KeyEscape) + if open || !hide { + t.Fatal("escape did not close or lost hide") + } +} + +func TestWhatsNewDialogSnapshot(t *testing.T) { + snapshot(t, "whats_new_dialog", 560, 460, func(c *ui.Context) { + open := ui.Local(c.Root(), "open", func() bool { return true }) + hide := ui.Local(c.Root(), "hide", func() bool { return false }) + WhatsNewDialog(c, open, hide, releases, WhatsNewDialogOptions{}) + }) +} + +func TestWhatsNewDialogDisabledParentCloses(t *testing.T) { + open, hide, disabled := true, false, false + tt := accountTester(700, 600, func(c *ui.Context) { + ui.Column(c).Disabled(disabled).Children(func() { WhatsNewDialog(c, &open, &hide, releases, WhatsNewDialogOptions{}) }) + }) + disabled = true + tt.Frame() + if open || hide || tt.HasText("What's new") { + t.Fatal("disabled parent retains dialog") + } +} + +func TestWhatsNewDialogInvalidChange(t *testing.T) { + open, hide := true, false + accountPanics(t, func(c *ui.Context) { + WhatsNewDialog(c, &open, &hide, []Release{{Version: "1", Changes: []ReleaseChange{{Kind: ChangeKind(99), Text: "Change"}}}}, WhatsNewDialogOptions{}) + }) +} diff --git a/workspace_switcher.go b/workspace_switcher.go new file mode 100644 index 0000000..6fcd0e2 --- /dev/null +++ b/workspace_switcher.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// Workspace is a place a user can switch to. +type Workspace struct { + ID, Name string + // Detail supplies the workspace summary. + Detail string +} + +// WorkspaceSwitcherOptions configure a WorkspaceSwitcher. +type WorkspaceSwitcherOptions struct { + Disabled bool +} + +type workspaceSwitcherState struct { + open bool + query string + cursor int + source []Workspace + hits []int + filtered string + ready bool +} + +// WorkspaceSwitcher filters workspaces and changes the current selection. +func WorkspaceSwitcher(c *ui.Context, current *string, spaces []Workspace, opts WorkspaceSwitcherOptions) ChoiceResult { + var cur *Workspace + for i := range spaces { + if spaces[i].ID == "" || spaces[i].Name == "" { + panic("mujica: Workspace needs an ID and a Name") + } + if spaces[i].ID == *current { + cur = &spaces[i] + } + } + if cur == nil { + panic(fmt.Sprintf("mujica: WorkspaceSwitcher current %q is not a workspace", *current)) + } + var r ChoiceResult + b := accountTrigger(c, cur.Name, cur.Detail, nil, opts.Disabled) + r.Element = b + st := ui.Local(b, "ws", func() workspaceSwitcherState { return workspaceSwitcherState{} }) + disabled := b.IsDisabled() + if disabled { + st.open = false + } else if b.Clicked() || b.Shortcut(0, ui.KeyDown) { + st.open, st.query, st.cursor = true, "", 0 + } + choose := func(i int) { + if !disabled && spaces[i].ID != *current { + *current, r.changed = spaces[i].ID, true + slog.Debug("mujica: workspace_switcher switched") + } + st.open = false + } + Popover(c, b, &st.open, PopoverOptions{Width: 300, Label: msg(c, "workspace_switcher.label")}, func() { + res := SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "workspace_switcher.search"), Label: msg(c, "workspace_switcher.search")}) + in := res.Input + in.AutoFocus() + q := strings.ToLower(strings.TrimSpace(st.query)) + if !st.ready || st.filtered != q || !slices.Equal(st.source, spaces) { + st.source, st.filtered, st.ready = slices.Clone(spaces), q, true + st.hits, st.cursor = st.hits[:0], 0 + for i, w := range spaces { + if q == "" || strings.Contains(strings.ToLower(w.Name+" "+w.Detail), q) { + st.hits = append(st.hits, i) + } + } + } + hits := st.hits + st.cursor = max(0, min(st.cursor, len(hits)-1)) + before := st.cursor + switch { + case disabled || in.IsDisabled() || 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 { + choose(hits[st.cursor]) + } + if len(hits) == 0 { + ui.Text(c, msg(c, "workspace_switcher.none")).TextColor(Tokens(c).TextMuted).Padding(Density(c).Unit() * 2).Role(ui.RoleStatus) + } + list := accountRows(c, len(hits), func(n int) any { return spaces[hits[n]].ID }, func(n int) { + i := hits[n] + row := workspaceRow(c, spaces[i], spaces[i].ID == *current, n == st.cursor) + if row.Clicked() && !row.IsDisabled() { + choose(i) + } + }) + if len(hits) > 8 && (st.cursor != before || res.Changed()) { + rows := ui.Local(list, "rows", func() ui.ListState { return ui.ListState{} }) + rows.ScrollIntoView(st.cursor) + } + }) + return r +} + +// workspaceRow displays a clickable workspace with a selection mark. +func workspaceRow(c *ui.Context, w Workspace, current, cursor bool) *ui.Element { + k := Tokens(c) + d := Density(c) + b := ui.ButtonBase(c).Key(w.ID).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(d.Unit() * 2). + Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(w.Name).Selected(current) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case cursor || current: + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Box(c).Size(16, 16).Shrink(0).Children(func() { + if current { + ui.Icon(c, icons.Must("check")).Size(16, 16).TextColor(k.AccentText) + } + }) + ui.Column(c).Grow(1).MinWidth(0).Children(func() { + ui.Text(c, w.Name).SingleLine().Ellipsis("…") + if w.Detail != "" { + ui.Text(c, w.Detail).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() + } + }) + }) + return b +} diff --git a/workspace_switcher_test.go b/workspace_switcher_test.go new file mode 100644 index 0000000..aa8e8b6 --- /dev/null +++ b/workspace_switcher_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var switcherSpaces = []Workspace{{ID: "ave", Name: "Ave Mujica", Detail: "5 members"}, {ID: "mygo", Name: "MyGO!!!!!", Detail: "5 members"}, {ID: "crychic", Name: "CRYCHIC", Detail: "Archived"}} + +func TestWorkspaceSwitcherSearchAndSwitch(t *testing.T) { + cur, n := "ave", 0 + tt := accountTester(500, 500, func(c *ui.Context) { + if WorkspaceSwitcher(c, &cur, switcherSpaces, WorkspaceSwitcherOptions{}).Changed() { + n++ + } + }) + if err := tt.Click("Ave Mujica"); err != nil { + t.Fatal(err) + } + if !tt.Focused("Find a workspace") || !tt.HasText("CRYCHIC") { + t.Fatalf("open: %q", tt.Texts()) + } + tt.Type("arch") + if tt.HasText("MyGO!!!!!") || !tt.HasText("CRYCHIC") { + t.Fatalf("search: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if cur != "crychic" || n != 1 || tt.HasText("Find a workspace") { + t.Fatalf("enter: %q", cur) + } + _ = tt.Click("CRYCHIC") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if cur != "mygo" || n != 2 { + t.Fatalf("arrow: %q", cur) + } + _ = tt.Click("MyGO!!!!!") + tt.Type("zzz") + if !tt.HasText("No workspace matches") { + t.Fatal("no empty state") + } + tt.Key(0, ui.KeyEscape) + if tt.HasText("No workspace matches") || cur != "mygo" { + t.Fatal("escape did not close") + } +} + +func TestWorkspaceSwitcherSnapshot(t *testing.T) { + snapshot(t, "workspace_switcher", 300, 90, func(c *ui.Context) { + cur := ui.Local(c.Root(), "cur", func() string { return "ave" }) + ui.Column(c).Padding(16).AlignItems(ui.Start).Children(func() { WorkspaceSwitcher(c, cur, switcherSpaces, WorkspaceSwitcherOptions{}) }) + }) +} + +func TestWorkspaceSwitcherLargeDataBuildsVisibleRows(t *testing.T) { + items := make([]Workspace, 100000) + for i := range items { + items[i] = Workspace{ID: fmt.Sprint(i), Name: fmt.Sprintf("Workspace %d", i)} + } + cur := "0" + tt := accountTester(500, 500, func(c *ui.Context) { WorkspaceSwitcher(c, &cur, items, WorkspaceSwitcherOptions{}) }) + if err := tt.Click("Workspace 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.Type("99999") + tt.Key(0, ui.KeyEnter) + if cur != "99999" { + t.Fatalf("large-list search choice %q", cur) + } +} + +func TestWorkspaceSwitcherDisablingClosesAndInvalidCurrent(t *testing.T) { + cur, disabled := "ave", false + tt := accountTester(600, 500, func(c *ui.Context) { + ui.Column(c).Disabled(disabled).Children(func() { WorkspaceSwitcher(c, &cur, switcherSpaces, WorkspaceSwitcherOptions{}) }) + }) + if err := tt.Click("Ave Mujica"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if tt.HasText("Find a workspace") || cur != "ave" { + t.Fatal("disabled workspace popup stays active") + } + cur = "unknown" + accountPanics(t, func(c *ui.Context) { WorkspaceSwitcher(c, &cur, switcherSpaces, WorkspaceSwitcherOptions{}) }) +}