WIP essentials 2
This commit is contained in:
13 files changed
+462
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "文字过长,无法生成二维码"},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Reference in new issue
Block a user