diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..db5f1e1 --- /dev/null +++ b/banner.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BannerOptions configure a Banner. +type BannerOptions struct { + Severity Severity + // Action labels a button after the message; empty shows none. + Action string +} + +// Banner is a full-width notice for the top of a page; a non-nil open adds a close button. +func Banner(c *ui.Context, open *bool, message string, opts BannerOptions) AlertResult { + if message == "" { + panic("mujica: Banner needs a message") + } + k := Tokens(c) + tone, icon := severityLook(k, opts.Severity) + if open != nil && !*open { + return AlertResult{} + } + u := Density(c).Unit() + name := msg(c, "alert."+opts.Severity.String()) + var res AlertResult + bar := ui.Row(c).FillWidth().MinHeight(Density(c).ControlHeight()+2*u).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*3, u*2, u*4). + Background(k.Surface.Mix(tone, 0.08)).Role(ui.RoleGroup).Label(name) + bar.Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, tone, 0) + }) + said := ui.Local(bar, "banner.said", func() string { return "" }) + if news := name + ": " + message; opts.Severity >= SeverityWarning && *said != news { + *said = news + c.Announce(news) + } + bar.Children(func() { + ui.Icon(c, icon).Size(20, 20).TextColor(tone) + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight) + if opts.Action != "" && Button(c, opts.Action, ButtonOptions{Variant: Outline}).Clicked() { + slog.Debug("mujica: banner action", "severity", opts.Severity) + res.Action = true + } + if open != nil && iconAction(c, icons.Must("x"), msg(c, "alert.close")).Clicked() { + slog.Debug("mujica: banner closed", "severity", opts.Severity) + *open, res.Closed = false, true + } + }) + res.Element = bar + return res +} diff --git a/banner_test.go b/banner_test.go new file mode 100644 index 0000000..bba858e --- /dev/null +++ b/banner_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBannerActionCloseAndAnnounce(t *testing.T) { + open := true + var last AlertResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := Banner(c, &open, "Maintenance tonight at 22:00", BannerOptions{Severity: SeverityWarning, Action: "Details"}); r.Action || r.Closed { + last = r + } + }, 700, 200) + if a := tt.Announcements(); !slices.Equal(a, []string{"Warning: Maintenance tonight at 22:00"}) { + t.Fatalf("announcements %q", a) + } + if err := tt.Click("Details"); err != nil { + t.Fatal(err) + } + if !last.Action || !open { + t.Fatalf("action: %+v open %v", last, open) + } + tt.Key(0, ui.KeyTab) // from Details to Dismiss + tt.Key(0, ui.KeyEnter) + if open || !last.Closed || tt.HasText("Maintenance") { + t.Fatalf("keyboard close: open %v %+v", open, last) + } +} + +func TestBannerDisabledAndFixed(t *testing.T) { + open := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + Banner(c, &open, "Read only", BannerOptions{Severity: SeverityError}) + }) + Banner(c, nil, "Always here", BannerOptions{}) + }, 700, 200) + _ = tt.Click("Dismiss") + if !open { + t.Fatal("disabled banner closed") + } + if !tt.HasText("Always here") { + t.Fatal("banner without open missing") + } + for _, f := range []func(c *ui.Context){ + func(c *ui.Context) { Banner(c, nil, "", BannerOptions{}) }, + func(c *ui.Context) { Banner(c, nil, "x", BannerOptions{Severity: 7}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Error("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 100) + }() + } +} + +func TestBannerSnapshot(t *testing.T) { + open := true + snapshot(t, "banner", 640, 200, func(c *ui.Context) { + ui.Column(c).Gap(8).Children(func() { + Banner(c, &open, "A new version is available.", BannerOptions{Action: "Update"}) + Banner(c, &open, "Your trial ends in 3 days.", BannerOptions{Severity: SeverityWarning}) + Banner(c, nil, "Sync is failing; changes stay on this device.", BannerOptions{Severity: SeverityError, Action: "Retry"}) + }) + }) +} diff --git a/copy_button.go b/copy_button.go new file mode 100644 index 0000000..2dd07c9 --- /dev/null +++ b/copy_button.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// copyButtonShown is how long the button says Copied. +const copyButtonShown = 2 * time.Second + +// CopyButtonOptions configure a CopyButton. +type CopyButtonOptions struct { + // Label shows beside the icon; empty makes a square icon button. + Label string + Disabled bool +} + +// CopyButton puts text on the clipboard, then shows and announces Copied for two seconds. +func CopyButton(c *ui.Context, text string, opts CopyButtonOptions) ChoiceResult { + copied := msg(c, "copy_button.copied") + name := msg(c, "copy_button.copy") + label, icon := opts.Label, icons.Must("copy") + var b *ui.Element + box := ui.Box(c) + at := ui.Local(box, "copy_button.at", func() time.Time { return time.Time{} }) + left := copyButtonShown - c.Now().Sub(*at) + done := !at.IsZero() && left > 0 + if done { + icon, name = icons.Must("check"), copied + if label != "" { + label = copied + } + c.After(left) + } + box.Children(func() { + bo := ButtonOptions{Variant: Secondary, Icon: icon, Disabled: opts.Disabled} + if opts.Label == "" { + bo.Label = name + } + b = Button(c, label, bo) + if opts.Label == "" { + b.Padding(0).Size(Density(c).ControlHeight(), Density(c).ControlHeight()).Tooltip(name) + } else { + // Room for the longer word, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: copied}) + w2, _ := c.MeasureText(0, ui.Span{Text: opts.Label}) + b.MinWidth(max(w1, w2) + buttonSpinnerSize + Density(c).Unit()*10 + 2*theme.BorderWidth) + } + }) + if b.Clicked() && !b.IsDisabled() { + c.WriteClipboard(text) + *at = c.Now() + c.Announce(copied) + c.Invalidate() + slog.Debug("mujica: copy_button copied", "bytes", len(text)) + return ChoiceResult{box, true} + } + return ChoiceResult{box, false} +} diff --git a/copy_button_test.go b/copy_button_test.go new file mode 100644 index 0000000..4e6d07b --- /dev/null +++ b/copy_button_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestCopyButtonCopiesAndAnnounces(t *testing.T) { + n := 0 + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + r := CopyButton(c, "sk-live-123", CopyButtonOptions{Label: "Copy key"}) + if r.Changed() { + n++ + } + w = r.Element.Bounds().W + }) + }, 400, 200) + tt.Frame() + before := w + if err := tt.Click("Copy key"); err != nil { + t.Fatal(err) + } + if tt.Clipboard() != "sk-live-123" || n != 1 { + t.Fatalf("clipboard %q, copies %d", tt.Clipboard(), n) + } + if !tt.HasText("Copied") || !slices.Contains(tt.Announcements(), "Copied") { + t.Fatalf("texts %q", tt.Texts()) + } + tt.Frame() + if w != before { + t.Fatalf("width %v changed from %v", w, before) + } +} + +func TestCopyButtonKeyboardRevertAndDisabled(t *testing.T) { + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + CopyButton(c, "abc", CopyButtonOptions{Disabled: dis}) + }, 300, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if tt.Clipboard() != "abc" { + t.Fatalf("Space copied %q", tt.Clipboard()) + } + if _, ok := tt.Find("已复制"); !ok { + t.Fatal("icon button lacks the copied name") + } + time.Sleep(copyButtonShown + 50*time.Millisecond) + tt.Frame() + if _, ok := tt.Find("复制"); !ok { + t.Fatal("did not revert to Copy") + } + tt.SetClipboard("") + dis = true + tt.Frame() + _ = tt.Click("复制") + if tt.Clipboard() != "" { + t.Fatal("disabled button copied") + } +} + +func TestCopyButtonSnapshot(t *testing.T) { + snapshot(t, "copy_button", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + CopyButton(c, "x", CopyButtonOptions{}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy link"}) + CopyButton(c, "x", CopyButtonOptions{Label: "Copy", Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index 88cfc55..ca8dd7e 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -2,6 +2,11 @@ package mujica func init() { addMessages(map[string][2]string{ + "copy_button.copy": {"Copy", "复制"}, + "copy_button.copied": {"Copied", "已复制"}, + + "split_button.more": {"More options", "更多选项"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/split_button.go b/split_button.go new file mode 100644 index 0000000..200d60a --- /dev/null +++ b/split_button.go @@ -0,0 +1,86 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SplitButtonOptions configure a SplitButton. +type SplitButtonOptions struct { + // Variant is Primary (the zero value) or Secondary. + Variant ButtonVariant + Icon *ui.SVG + // MenuLabel names the menu half; empty uses "More options". + MenuLabel string + Disabled bool +} + +// SplitButton runs its main action from the left half and opens build's menu from the right; each half takes focus. +func SplitButton(c *ui.Context, label string, opts SplitButtonOptions, build func(m *PopupMenu)) ChoiceResult { + if label == "" || build == nil { + panic("mujica: SplitButton needs a label and a menu") + } + if opts.Variant != Primary && opts.Variant != Secondary { + panic(fmt.Sprintf("mujica: SplitButton Variant %d must be Primary or Secondary", opts.Variant)) + } + k := Tokens(c) + d := Density(c) + menuName := opts.MenuLabel + if menuName == "" { + menuName = msg(c, "split_button.more") + } + group := ui.Row(c).AlignItems(ui.Stretch).Disabled(opts.Disabled) + disabled := group.IsDisabled() + open := ui.Local(group, "split_button.open", func() bool { return false }) + if disabled { + *open = false + } + var clicked bool + group.Children(func() { + main := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*4).Gap(d.Unit()*2). + Radius(theme.ControlRadius, 0, 0, theme.ControlRadius) + splitButtonLook(c, k, main, opts.Variant, false) + main.Children(func() { + if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(16, 16) + } + ui.Text(c, label).SingleLine() + }) + clicked = main.Clicked() && !disabled + if opts.Variant == Primary { + ui.Box(c).Width(theme.BorderWidth).Background(k.OnAccent.Alpha(0.45)).Role(ui.RoleNone) + } + more := ui.ButtonBase(c).Role(ui.RoleMenuButton).Label(menuName).MinHeight(d.ControlHeight()).Width(theme.MinHitSize+d.Unit()). + Radius(0, theme.ControlRadius, theme.ControlRadius, 0).Tooltip(menuName) + splitButtonLook(c, k, more, opts.Variant, *open) + if opts.Variant == Secondary { + more.BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 0) + } + more.Children(func() { ui.Icon(c, icons.Must("chevron-down")).Size(14, 14) }) + if !disabled && (more.Clicked() || more.Shortcut(0, ui.KeyDown)) { + *open = true + slog.Debug("mujica: split_button open") + } + popupMenuOpen(c, group, open, build) + }) + if clicked { + slog.Debug("mujica: split_button action", "label", label) + } + return ChoiceResult{group, clicked} +} + +// splitButtonLook colors one half as a Button of variant v would be. +func splitButtonLook(c *ui.Context, k theme.Tokens, b *ui.Element, v ButtonVariant, open bool) { + internal.Ring(b, k.Focus, theme.ControlRadius) + bg, fg, border := buttonColors(k, v, b.Hovered() && !b.IsDisabled(), b.Pressed() || open) + b.Background(bg).TextColor(fg).Transition(colorFade) + if border.A > 0 { + b.Border(theme.BorderWidth, border) + } +} diff --git a/split_button_test.go b/split_button_test.go new file mode 100644 index 0000000..166d2c1 --- /dev/null +++ b/split_button_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func splitButtonTester(dis *bool, runs *int, chosen *string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + r := SplitButton(c, "Save", SplitButtonOptions{Disabled: *dis}, func(m *PopupMenu) { + for _, s := range []string{"Save as…", "Save all"} { + if m.Item(s, PopupMenuItemOptions{}) { + *chosen = s + } + } + }) + if r.Changed() { + *runs++ + } + }) + }, 500, 300) +} + +func TestSplitButtonMainAndMenu(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + if err := tt.Click("Save"); err != nil { + t.Fatal(err) + } + if runs != 1 || tt.HasText("Save all") { + t.Fatalf("main click: runs %d, menu open %v", runs, tt.HasText("Save all")) + } + if err := tt.Click("More options"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Save all"); err != nil { + t.Fatal(err) + } + if chosen != "Save all" || runs != 1 || tt.HasText("Save as…") { + t.Fatalf("menu choice %q runs %d", chosen, runs) + } +} + +func TestSplitButtonKeyboardHalves(t *testing.T) { + dis, runs, chosen := false, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Save") { + t.Fatal("first Tab misses the main half") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyTab) + if !tt.Focused("More options") { + t.Fatal("second Tab misses the menu half") + } + tt.Key(0, ui.KeyDown) + if runs != 1 || !tt.HasText("Save as…") { + t.Fatalf("runs %d, menu %v", runs, tt.HasText("Save as…")) + } + tt.Key(0, ui.KeyEnter) + if chosen != "Save as…" { + t.Fatalf("Enter chose %q", chosen) + } +} + +func TestSplitButtonDisabled(t *testing.T) { + dis, runs, chosen := true, 0, "" + tt := splitButtonTester(&dis, &runs, &chosen) + _ = tt.Click("Save") + _ = tt.Click("More options") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if runs != 0 || tt.HasText("Save all") { + t.Fatalf("disabled: runs %d menu %v", runs, tt.HasText("Save all")) + } + defer func() { + if recover() == nil { + t.Fatal("Danger variant accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SplitButton(c, "x", SplitButtonOptions{Variant: Danger}, func(*PopupMenu) {}) + }, 200, 100) +} + +func TestSplitButtonSnapshot(t *testing.T) { + snapshot(t, "split_button", 460, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + menu := func(m *PopupMenu) { m.Item("Other", PopupMenuItemOptions{}) } + SplitButton(c, "Publish", SplitButtonOptions{}, menu) + SplitButton(c, "Export", SplitButtonOptions{Variant: Secondary}, menu) + SplitButton(c, "Merge", SplitButtonOptions{Disabled: true}, menu) + }) + }) +} diff --git a/testdata/snapshots/darwin/banner-dark.png b/testdata/snapshots/darwin/banner-dark.png new file mode 100644 index 0000000..9ec220a Binary files /dev/null and b/testdata/snapshots/darwin/banner-dark.png differ diff --git a/testdata/snapshots/darwin/banner-light.png b/testdata/snapshots/darwin/banner-light.png new file mode 100644 index 0000000..c465661 Binary files /dev/null and b/testdata/snapshots/darwin/banner-light.png differ diff --git a/testdata/snapshots/darwin/copy_button-dark.png b/testdata/snapshots/darwin/copy_button-dark.png new file mode 100644 index 0000000..b059394 Binary files /dev/null and b/testdata/snapshots/darwin/copy_button-dark.png differ diff --git a/testdata/snapshots/darwin/copy_button-light.png b/testdata/snapshots/darwin/copy_button-light.png new file mode 100644 index 0000000..f5668c3 Binary files /dev/null and b/testdata/snapshots/darwin/copy_button-light.png differ diff --git a/testdata/snapshots/darwin/split_button-dark.png b/testdata/snapshots/darwin/split_button-dark.png new file mode 100644 index 0000000..a198882 Binary files /dev/null and b/testdata/snapshots/darwin/split_button-dark.png differ diff --git a/testdata/snapshots/darwin/split_button-light.png b/testdata/snapshots/darwin/split_button-light.png new file mode 100644 index 0000000..892ce9e Binary files /dev/null and b/testdata/snapshots/darwin/split_button-light.png differ