WIP essentials 2

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:08:29 -04:00
1 parent f7096b13f9
commit 87a064d44f
13 files changed
+462

No files matched your search

+56
View File
@@ -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
}
+75
View File
@@ -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"})
})
})
}
+64
View File
@@ -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}
}
+77
View File
@@ -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})
})
})
}
+5
View File
@@ -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", "文字过长,无法生成二维码"},
})
+86
View File
@@ -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)
}
}
+99
View File
@@ -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)
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB