Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AvatarOptions configure an Avatar.
|
||||
type AvatarOptions struct {
|
||||
// Image is the picture; nil falls back to initials, icon.
|
||||
Image *ui.Bitmap
|
||||
// Size is the diameter in DIPs; zero means 32.
|
||||
Size float32
|
||||
}
|
||||
|
||||
// Avatar shows a person as picture, initials or icon.
|
||||
func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element {
|
||||
size := opts.Size
|
||||
if size < 0 {
|
||||
panic("mujica: Avatar Size must not be negative")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 32
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
label := name
|
||||
if label == "" {
|
||||
label = core.Msg(c, "avatar.unnamed")
|
||||
}
|
||||
e := ui.Box(c).Size(size, size).Radius(size / 2).Clip().Shrink(0).Center().Role(ui.RoleImage).Label(label)
|
||||
initials := avatarInitials(name)
|
||||
e.Children(func() {
|
||||
switch {
|
||||
case opts.Image != nil:
|
||||
ui.Image(c, opts.Image).Fit(ui.Cover).Size(size, size)
|
||||
case initials != "":
|
||||
e.Background(k.Selection).Border(theme.BorderWidth, k.Border)
|
||||
ui.Text(c, initials).Font(theme.SerifFont).FontSize(size * avatarInitialsScale(initials)).FontWeight(600).
|
||||
TextColor(k.AccentText).SingleLine()
|
||||
default:
|
||||
e.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
|
||||
Icon(c, "user", IconOptions{Size: size * 0.5, Tone: IconMuted})
|
||||
}
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// avatarInitials returns first letters of first and last words.
|
||||
func avatarInitials(name string) string {
|
||||
var first, last rune
|
||||
inWord := false
|
||||
for _, r := range name {
|
||||
letter := unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
if letter && !inWord {
|
||||
if first == 0 {
|
||||
first = unicode.ToUpper(r)
|
||||
} else {
|
||||
last = unicode.ToUpper(r)
|
||||
}
|
||||
}
|
||||
inWord = letter
|
||||
}
|
||||
switch {
|
||||
case first == 0:
|
||||
return ""
|
||||
case last == 0:
|
||||
return string(first)
|
||||
}
|
||||
return string([]rune{first, last})
|
||||
}
|
||||
|
||||
// avatarInitialsScale fits two letters inside the circle.
|
||||
func avatarInitialsScale(initials string) float32 {
|
||||
if len([]rune(initials)) > 1 {
|
||||
return 0.34
|
||||
}
|
||||
return 0.42
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AvatarItem is one person of an AvatarGroup.
|
||||
type AvatarItem struct {
|
||||
Name string
|
||||
Image *ui.Bitmap
|
||||
}
|
||||
|
||||
// AvatarGroupOptions configure an AvatarGroup.
|
||||
type AvatarGroupOptions struct {
|
||||
// Max avatars show before the "+N" button; zero means 4.
|
||||
Max int
|
||||
// Size is each avatar's diameter; zero means 32.
|
||||
Size float32
|
||||
}
|
||||
|
||||
type avatarGroupKey struct{}
|
||||
|
||||
// AvatarGroupView is a shown AvatarGroup and its full list's state.
|
||||
type AvatarGroupView struct {
|
||||
Element *ui.Element
|
||||
// Open reports whether the full list shows.
|
||||
Open bool
|
||||
// Toggled reports the full list opened or closed this frame.
|
||||
Toggled bool
|
||||
}
|
||||
|
||||
// AvatarGroup overlaps avatars; "+N" opens the full list.
|
||||
func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) AvatarGroupView {
|
||||
limit := opts.Max
|
||||
if limit < 0 {
|
||||
panic("mujica: AvatarGroup Max must not be negative")
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 4
|
||||
}
|
||||
size := opts.Size
|
||||
if size < 0 {
|
||||
panic("mujica: AvatarGroup Size must not be negative")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 32
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
shown := min(limit, len(items))
|
||||
ring := float32(2)
|
||||
row := ui.Row(c).AlignItems(ui.Center).Role(ui.RoleGroup)
|
||||
open := ui.Local(row, avatarGroupKey{}, func() bool { return false })
|
||||
was := *open
|
||||
row.Children(func() {
|
||||
for i, it := range items[:shown] {
|
||||
cell := ui.Box(c).Key(i).Padding(ring).Radius(size/2 + ring).Background(k.Background).Shrink(0)
|
||||
if i > 0 {
|
||||
cell.Margin(0, 0, 0, -size/4)
|
||||
}
|
||||
cell.Children(func() { Avatar(c, it.Name, AvatarOptions{Image: it.Image, Size: size}) })
|
||||
}
|
||||
rest := len(items) - shown
|
||||
if rest == 0 {
|
||||
*open = false
|
||||
return
|
||||
}
|
||||
more := avatarGroupMore(c, k, open, size, ring, rest, len(items))
|
||||
ui.PopoverBase(c, more, open, func(panel *ui.Element) {
|
||||
avatarGroupList(c, k, panel, items)
|
||||
})
|
||||
})
|
||||
v := AvatarGroupView{Element: row, Open: *open, Toggled: *open != was}
|
||||
if v.Toggled {
|
||||
slog.Debug("mujica: avatargroup toggle", "open", v.Open)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// avatarGroupMore is the "+N" disclosure toggling *open.
|
||||
func avatarGroupMore(c *ui.Context, k theme.Tokens, open *bool, size, ring float32, rest, total int) *ui.Element {
|
||||
hit := max(size+2*ring, theme.MinHitSize)
|
||||
var b *ui.Element
|
||||
ui.Box(c).Key("more").Margin(0, 0, 0, -size/4).Shrink(0).Children(func() {
|
||||
b = ui.CollapsibleBase(c, open).Trigger.Size(hit, hit).Center().Radius(hit / 2).
|
||||
Label(fmt.Sprintf(core.Msg(c, "avatar_group.more"), total))
|
||||
internal.Ring(b, k.Focus, hit/2)
|
||||
bg := k.Surface
|
||||
if b.Pressed() {
|
||||
bg = k.Selection
|
||||
} else if b.Hovered() {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Box(c).Padding(ring).Radius(size/2 + ring).Background(k.Background).Children(func() {
|
||||
disc := ui.Box(c).Size(size, size).Center().Radius(size/2).Background(bg).
|
||||
Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Text)
|
||||
disc.Transition(core.ColorFade)
|
||||
disc.Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("+%d", rest)).FontSize(size * 0.36).FontWeight(600).SingleLine()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func avatarGroupList(c *ui.Context, k theme.Tokens, panel *ui.Element, items []AvatarItem) {
|
||||
u := core.Density(c).Unit()
|
||||
panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).MinWidth(200)
|
||||
if d := core.Motion(c, theme.PopupDuration); d > 0 {
|
||||
panel.Transition(ui.ElementTransition{Duration: d, Enter: &ui.Motion{Y: -4}})
|
||||
}
|
||||
ui.Scroll(c).MaxHeight(240).Children(func() {
|
||||
ui.Column(c).Gap(u * 2).Role(ui.RoleList).Children(func() {
|
||||
for i, it := range items {
|
||||
ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
|
||||
Avatar(c, it.Name, AvatarOptions{Image: it.Image, Size: 24})
|
||||
ui.Text(c, it.Name).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var avatarGroupPeople = []AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
|
||||
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"},
|
||||
}
|
||||
|
||||
func TestAvatarGroupMaxAndRest(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 3})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if !tt.HasText("+3") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Umiri Yahata"); ok {
|
||||
t.Fatal("fourth avatar shown past Max")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarGroupNoOverflowNoButton(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AvatarGroup(c, avatarGroupPeople[:2], AvatarGroupOptions{})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if tt.HasText("+") {
|
||||
t.Fatalf("overflow button with 2 of 4: %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarGroupExpandByKeyboard(t *testing.T) {
|
||||
var toggles []bool
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if v := AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 2}); v.Toggled {
|
||||
toggles = append(toggles, v.Open)
|
||||
}
|
||||
}, 400, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Show all 6 people") {
|
||||
t.Fatal("overflow button not focused")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
for _, p := range avatarGroupPeople {
|
||||
if !tt.HasText(p.Name) {
|
||||
t.Fatalf("list lacks %q: %v", p.Name, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Tomori Takamatsu") {
|
||||
t.Fatal("list still open after Escape")
|
||||
}
|
||||
if len(toggles) != 2 || !toggles[0] || toggles[1] {
|
||||
t.Fatalf("toggles %v, want [true false]", toggles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarGroupExpandByClick(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 4})
|
||||
}, 400, 400)
|
||||
if err := tt.Click("+2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Tomori Takamatsu") {
|
||||
t.Fatal("list not open")
|
||||
}
|
||||
tt.ClickAt(390, 390)
|
||||
if tt.HasText("Tomori Takamatsu") {
|
||||
t.Fatal("list open after outside click")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarGroupSnapshot(t *testing.T) {
|
||||
snapshot(t, "avatar_group", 320, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(16).AlignItems(ui.Start).Children(func() {
|
||||
AvatarGroup(c, avatarGroupPeople[:3], AvatarGroupOptions{})
|
||||
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 3, Size: 40})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAvatarGroupSmallButtonHitArea(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
AvatarGroup(c, avatarGroupPeople, AvatarGroupOptions{Max: 2, Size: 20})
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Show all 6 people")
|
||||
if !ok || r.W < 28 || r.H < 28 {
|
||||
t.Fatalf("hit area %v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestAvatarInitials(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"Sakiko Togawa": "ST",
|
||||
"uika": "U",
|
||||
"Anon Chihaya Mortis": "AM",
|
||||
" ": "",
|
||||
"张伟": "张",
|
||||
"mutsumi (wakaba) 若叶睦": "M若",
|
||||
} {
|
||||
if got := avatarInitials(name); got != want {
|
||||
t.Errorf("%q: %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarFallbacks(t *testing.T) {
|
||||
var size ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
size = Avatar(c, "Sakiko Togawa", AvatarOptions{Size: 48}).Bounds()
|
||||
Avatar(c, "", AvatarOptions{})
|
||||
Avatar(c, "Photo Person", AvatarOptions{Image: imageTestBitmap()})
|
||||
})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if size.W != 48 || size.H != 48 {
|
||||
t.Fatalf("size %v", size)
|
||||
}
|
||||
if !tt.HasText("ST") {
|
||||
t.Fatal("initials missing")
|
||||
}
|
||||
if _, ok := tt.Find("Unnamed"); !ok {
|
||||
t.Fatal("unnamed avatar not labeled")
|
||||
}
|
||||
if _, ok := tt.Find("Photo Person"); !ok {
|
||||
t.Fatal("image avatar not labeled")
|
||||
}
|
||||
if tt.HasText("PP") {
|
||||
t.Fatal("initials shown over image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarSnapshot(t *testing.T) {
|
||||
snapshot(t, "avatar", 320, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
Avatar(c, "Sakiko Togawa", AvatarOptions{Size: 48})
|
||||
Avatar(c, "Uika", AvatarOptions{})
|
||||
Avatar(c, "", AvatarOptions{Size: 40})
|
||||
Avatar(c, "Photo", AvatarOptions{Image: imageTestBitmap(), Size: 40})
|
||||
Avatar(c, "Mutsumi Wakaba", AvatarOptions{Size: 24})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BadgeTone is a badge's emphasis. The zero value is BadgeAccent.
|
||||
type BadgeTone int
|
||||
|
||||
const (
|
||||
BadgeAccent BadgeTone = iota
|
||||
BadgeNeutral
|
||||
BadgeSuccess
|
||||
BadgeWarning
|
||||
BadgeDanger
|
||||
BadgeInfo
|
||||
)
|
||||
|
||||
// BadgePosition places a badge; zero is inline.
|
||||
type BadgePosition int
|
||||
|
||||
const (
|
||||
BadgeInline BadgePosition = iota
|
||||
BadgeTopRight
|
||||
BadgeTopLeft
|
||||
BadgeBottomRight
|
||||
BadgeBottomLeft
|
||||
)
|
||||
|
||||
// BadgeOptions configure a Badge.
|
||||
type BadgeOptions struct {
|
||||
// Text shows as is; when empty, Count shows instead.
|
||||
Text string
|
||||
// Count must not be negative; zero without Text hides it.
|
||||
Count int
|
||||
// Max caps the shown count as "Max+"; zero means 99.
|
||||
Max int
|
||||
Tone BadgeTone
|
||||
Position BadgePosition
|
||||
// Label says what is counted; read before the full value.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Badge shows text or a count, inline or cornered.
|
||||
func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element {
|
||||
if opts.Count < 0 || opts.Max < 0 {
|
||||
panic("mujica: Badge Count and Max must not be negative")
|
||||
}
|
||||
if (opts.Position == BadgeInline) != (content == nil) {
|
||||
panic("mujica: Badge needs content exactly when Position is not BadgeInline")
|
||||
}
|
||||
shown, full := badgeText(opts)
|
||||
if opts.Position == BadgeInline {
|
||||
if shown == "" {
|
||||
return ui.Box(c).Size(0, 0)
|
||||
}
|
||||
return badgePill(c, opts, shown, full)
|
||||
}
|
||||
at := badgeAnchor(opts.Position)
|
||||
wrap := ui.Box(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Start)
|
||||
wrap.Children(func() {
|
||||
content()
|
||||
if shown != "" {
|
||||
badgePill(c, opts, shown, full).Attach(at, ui.AnchorCenter)
|
||||
}
|
||||
})
|
||||
return wrap
|
||||
}
|
||||
|
||||
// badgeText returns what the badge shows and what it reads.
|
||||
func badgeText(opts BadgeOptions) (shown, full string) {
|
||||
if opts.Text != "" {
|
||||
return opts.Text, opts.Text
|
||||
}
|
||||
if opts.Count == 0 {
|
||||
return "", ""
|
||||
}
|
||||
limit := opts.Max
|
||||
if limit == 0 {
|
||||
limit = 99
|
||||
}
|
||||
full = strconv.Itoa(opts.Count)
|
||||
if opts.Count > limit {
|
||||
return strconv.Itoa(limit) + "+", full
|
||||
}
|
||||
return full, full
|
||||
}
|
||||
|
||||
func badgePill(c *ui.Context, opts BadgeOptions, shown, full string) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
bg, fg, border := badgeColors(k, opts.Tone)
|
||||
label := full
|
||||
if opts.Label != "" {
|
||||
label = opts.Label + " " + full
|
||||
}
|
||||
p := ui.Box(c).Row().Center().MinWidth(core.FontSize(c, 18)).MinHeight(core.FontSize(c, 18)).Padding(0, 5).Radius(theme.CardRadius).
|
||||
Background(bg).Shrink(0).Role(ui.RoleStatus).Label(label)
|
||||
if border.A > 0 {
|
||||
p.Border(theme.BorderWidth, border)
|
||||
}
|
||||
p.Children(func() {
|
||||
ui.Text(c, shown).FontSize(core.FontSize(c, theme.CaptionSize-1)).FontWeight(600).TextColor(fg).
|
||||
FontFeatures("tnum").SingleLine()
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
func badgeColors(k theme.Tokens, t BadgeTone) (bg, fg, border ui.Color) {
|
||||
switch t {
|
||||
case BadgeAccent:
|
||||
return k.Accent, k.OnAccent, ui.Color{}
|
||||
case BadgeNeutral:
|
||||
return k.SurfaceHover, k.Text, k.Border
|
||||
case BadgeSuccess:
|
||||
return k.Surface, k.Success, k.Success
|
||||
case BadgeWarning:
|
||||
return k.Surface, k.Warning, k.Warning
|
||||
case BadgeDanger:
|
||||
return k.DangerFill, k.OnDanger, ui.Color{}
|
||||
case BadgeInfo:
|
||||
return k.Surface, k.Info, k.Info
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown BadgeTone %d", t))
|
||||
}
|
||||
|
||||
func badgeAnchor(p BadgePosition) ui.Anchor {
|
||||
switch p {
|
||||
case BadgeTopRight:
|
||||
return ui.AnchorTopRight
|
||||
case BadgeTopLeft:
|
||||
return ui.AnchorTopLeft
|
||||
case BadgeBottomRight:
|
||||
return ui.AnchorBottomRight
|
||||
case BadgeBottomLeft:
|
||||
return ui.AnchorBottomLeft
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown BadgePosition %d", p))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestBadgeText(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
opts BadgeOptions
|
||||
shown, full string
|
||||
}{
|
||||
{BadgeOptions{Count: 7}, "7", "7"},
|
||||
{BadgeOptions{Count: 99}, "99", "99"},
|
||||
{BadgeOptions{Count: 100}, "99+", "100"},
|
||||
{BadgeOptions{Count: 12, Max: 9}, "9+", "12"},
|
||||
{BadgeOptions{Count: 0}, "", ""},
|
||||
{BadgeOptions{Text: "New", Count: 5}, "New", "New"},
|
||||
} {
|
||||
shown, full := badgeText(tc.opts)
|
||||
if shown != tc.shown || full != tc.full {
|
||||
t.Errorf("%+v: %q %q", tc.opts, shown, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeReadsFullValue(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Badge(c, BadgeOptions{Count: 1204, Label: "Unread"}, nil)
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if !tt.HasText("99+") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Unread 1204"); !ok {
|
||||
t.Fatal("full value not read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeCornerPosition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
pos BadgePosition
|
||||
right, down bool
|
||||
}{{BadgeTopRight, true, false}, {BadgeTopLeft, false, false}, {BadgeBottomRight, true, true}, {BadgeBottomLeft, false, true}} {
|
||||
var host ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(40).Children(func() {
|
||||
Badge(c, BadgeOptions{Count: 3, Position: tc.pos}, func() {
|
||||
host = ui.Box(c).Size(60, 60).Bounds()
|
||||
})
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("3")
|
||||
if !ok {
|
||||
t.Fatalf("%v: badge missing", tc.pos)
|
||||
}
|
||||
cx, cy := r.X+r.W/2, r.Y+r.H/2
|
||||
wantX, wantY := host.X, host.Y
|
||||
if tc.right {
|
||||
wantX += host.W
|
||||
}
|
||||
if tc.down {
|
||||
wantY += host.H
|
||||
}
|
||||
if d := cx - wantX; d < -2 || d > 2 {
|
||||
t.Errorf("%v: x %v, want %v", tc.pos, cx, wantX)
|
||||
}
|
||||
if d := cy - wantY; d < -2 || d > 2 {
|
||||
t.Errorf("%v: y %v, want %v", tc.pos, cy, wantY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeBadOptionsPanic(t *testing.T) {
|
||||
for i, build := range []func(c *ui.Context){
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: -1}, nil) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Position: BadgeTopRight}, nil) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1}, func() {}) },
|
||||
func(c *ui.Context) { Badge(c, BadgeOptions{Count: 1, Tone: BadgeTone(42)}, nil) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("case %d: no panic", i)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
build(c)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeSnapshot(t *testing.T) {
|
||||
snapshot(t, "badge", 460, 100, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() {
|
||||
Badge(c, BadgeOptions{Count: 7}, nil)
|
||||
Badge(c, BadgeOptions{Count: 250}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Draft", Tone: BadgeNeutral}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Live", Tone: BadgeSuccess}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Late", Tone: BadgeWarning}, nil)
|
||||
Badge(c, BadgeOptions{Text: "Info", Tone: BadgeInfo}, nil)
|
||||
Badge(c, BadgeOptions{Count: 3, Tone: BadgeDanger, Position: BadgeTopRight}, func() {
|
||||
Avatar(c, "Sakiko Togawa", AvatarOptions{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBadgeTextScale(t *testing.T) {
|
||||
var w [2]float32
|
||||
for i, s := range []float32{1, 2} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
w[i] = Badge(c, BadgeOptions{Text: "Draft"}, nil).Bounds().W
|
||||
})
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: s})
|
||||
tt.Frame()
|
||||
}
|
||||
if w[1] < w[0]*1.5 {
|
||||
t.Fatalf("width %v at 2x, %v at 1x", w[1], w[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package display holds MujicaUI text, headings, icons, images, avatars and other read-only display.
|
||||
package display
|
||||
@@ -0,0 +1,137 @@
|
||||
package display_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/display"
|
||||
)
|
||||
|
||||
func ExampleText() {
|
||||
name := "Coronation.md"
|
||||
saveErr := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
display.Text(c, "Order ID: 4821-AX", display.TextOptions{Selectable: true})
|
||||
display.Text(c, "A long note that stops after two lines.", display.TextOptions{MaxLines: 2})
|
||||
if display.EditableText(c, &name, display.EditableTextOptions{Error: saveErr}).Changed() {
|
||||
saveErr = "" // save name here; set saveErr on failure
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHeading() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
display.Heading(c, "The Grand Hall", display.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
display.Heading(c, "Guest Ledger", display.HeadingOptions{Level: 2})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleIcon() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
display.Icon(c, "star", display.IconOptions{})
|
||||
display.Icon(c, "circle-check", display.IconOptions{Size: 20, Tone: display.IconSuccess, Label: "Done"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleImage() {
|
||||
var photo *ui.Bitmap // decode once with ui.DecodeBitmap, outside the view
|
||||
loading, loadErr := false, errors.New("timeout")
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
img := display.Image(c, photo, display.ImageOptions{
|
||||
Width: 160, Height: 120, Crop: true, Alt: "Hall of mirrors",
|
||||
Loading: loading, Err: loadErr,
|
||||
})
|
||||
if img.Retry {
|
||||
loading, loadErr = true, nil // start loading again here
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvatar() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
display.Avatar(c, "Sakiko Togawa", display.AvatarOptions{Size: 40})
|
||||
display.Avatar(c, "", display.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvatarGroup() {
|
||||
people := []display.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
|
||||
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
display.AvatarGroup(c, people, display.AvatarGroupOptions{Max: 3})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBadge() {
|
||||
unread := 120
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
display.Badge(c, display.BadgeOptions{Text: "New", Tone: display.BadgeNeutral}, nil)
|
||||
display.Badge(c, display.BadgeOptions{Count: unread, Label: "Unread", Position: display.BadgeTopRight}, func() {
|
||||
display.Avatar(c, "Sakiko Togawa", display.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTag() {
|
||||
tags := []string{"Baroque", "Gothic"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for i, t := range tags {
|
||||
var v display.TagView
|
||||
ui.Box(c).Key(t).Children(func() {
|
||||
v = display.Tag(c, t, display.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
})
|
||||
if v.Closed {
|
||||
tags = append(tags[:i:i], tags[i+1:]...)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleLink() {
|
||||
details := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
display.Link(c, "Read the charter", display.LinkOptions{URL: "https://example.com/charter"})
|
||||
if display.Link(c, "Show details", display.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleKbd() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Gap(6).Children(func() {
|
||||
display.Text(c, "Command palette", display.TextOptions{})
|
||||
display.Kbd(c, "Mod", "Shift", "P")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// HeadingOptions configure a Heading; zero is level 1.
|
||||
type HeadingOptions struct {
|
||||
// Level is 1, 2 or 3; zero means 1.
|
||||
Level int
|
||||
// Subtitle shows below the title in muted text.
|
||||
Subtitle string
|
||||
// Divider draws an ornament rule with a center diamond below.
|
||||
Divider bool
|
||||
}
|
||||
|
||||
// Heading shows a serif title scaled with desktop text.
|
||||
func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element {
|
||||
k := core.Tokens(c)
|
||||
size := headingSize(opts.Level)
|
||||
u := core.Density(c).Unit()
|
||||
col := ui.Column(c).Gap(u).Shrink(0)
|
||||
col.Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, size)).FontWeight(600).
|
||||
LineHeight(1.25).TextColor(k.Text)
|
||||
if opts.Subtitle != "" {
|
||||
ui.Text(c, opts.Subtitle).FontSize(core.FontSize(c, theme.BodySize)).LineHeight(theme.LineHeight).TextColor(k.TextMuted)
|
||||
}
|
||||
if opts.Divider {
|
||||
ui.Box(c).FillWidth().Height(9).Margin(u, 0, 0, 0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
internal.RuleDiamond(p, r, 6, 7, k.Ornament)
|
||||
})
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
func headingSize(level int) float32 {
|
||||
switch level {
|
||||
case 0, 1:
|
||||
return theme.H1Size
|
||||
case 2:
|
||||
return theme.H2Size
|
||||
case 3:
|
||||
return theme.H3Size
|
||||
}
|
||||
panic("mujica: Heading Level must be 1, 2 or 3")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func headingHeights(t *testing.T, p ui.Preferences) (h1, h2, h3 float32) {
|
||||
t.Helper()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
h1 = Heading(c, "Court", HeadingOptions{}).Bounds().H
|
||||
h2 = Heading(c, "Court", HeadingOptions{Level: 2}).Bounds().H
|
||||
h3 = Heading(c, "Court", HeadingOptions{Level: 3}).Bounds().H
|
||||
})
|
||||
}, 400, 400)
|
||||
tt.SetPreferences(p)
|
||||
tt.Frame()
|
||||
return
|
||||
}
|
||||
|
||||
func TestHeadingLevels(t *testing.T) {
|
||||
h1, h2, h3 := headingHeights(t, ui.Preferences{TextScale: 1})
|
||||
if !(h1 > h2 && h2 > h3 && h3 > 0) {
|
||||
t.Fatalf("heights %v %v %v not decreasing", h1, h2, h3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingTextScale(t *testing.T) {
|
||||
h1, _, _ := headingHeights(t, ui.Preferences{TextScale: 1})
|
||||
big, _, _ := headingHeights(t, ui.Preferences{TextScale: 2})
|
||||
if big < h1*1.8 {
|
||||
t.Fatalf("scaled height %v, base %v", big, h1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingSubtitleAndDivider(t *testing.T) {
|
||||
var plain, full float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
plain = Heading(c, "Archive", HeadingOptions{Level: 2}).Bounds().H
|
||||
full = Heading(c, "Ledger", HeadingOptions{Level: 2, Subtitle: "Entries since 1802", Divider: true}).Bounds().H
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Entries since 1802") {
|
||||
t.Fatal("subtitle missing")
|
||||
}
|
||||
if full < plain+20 {
|
||||
t.Fatalf("subtitle and divider add %v DIPs", full-plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingBadLevelPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Heading(c, "x", HeadingOptions{Level: 4})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestHeadingSnapshot(t *testing.T) {
|
||||
snapshot(t, "heading", 420, 260, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(16).Width(380).Children(func() {
|
||||
Heading(c, "The Grand Hall", HeadingOptions{Subtitle: "Ceremonies of the winter season", Divider: true})
|
||||
Heading(c, "Guest Ledger", HeadingOptions{Level: 2})
|
||||
Heading(c, "宫廷礼仪", HeadingOptions{Level: 3, Subtitle: "第三章"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// IconTone is an icon's semantic color; zero inherits text color.
|
||||
type IconTone int
|
||||
|
||||
const (
|
||||
IconInherit IconTone = iota
|
||||
IconMuted
|
||||
IconAccent
|
||||
IconSuccess
|
||||
IconWarning
|
||||
IconDanger
|
||||
IconInfo
|
||||
)
|
||||
|
||||
// IconOptions configure an Icon.
|
||||
type IconOptions struct {
|
||||
// Size is width and height in DIPs; zero means 16.
|
||||
Size float32
|
||||
Tone IconTone
|
||||
// Label names the icon for assistive technology; empty hides it.
|
||||
Label string
|
||||
}
|
||||
|
||||
type iconMissingKey struct{}
|
||||
|
||||
// Icon shows a named icon; missing ones show a fallback.
|
||||
func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element {
|
||||
if name == "" {
|
||||
panic("mujica: Icon needs a name")
|
||||
}
|
||||
size := opts.Size
|
||||
if size < 0 {
|
||||
panic("mujica: Icon Size must not be negative")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 16
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
svg, ok := icons.Get(name)
|
||||
if !ok {
|
||||
return iconMissing(c, name, size, k)
|
||||
}
|
||||
e := ui.Icon(c, svg).Size(size, size).Shrink(0)
|
||||
if col, set := iconColor(k, opts.Tone); set {
|
||||
e.TextColor(col)
|
||||
}
|
||||
if opts.Label != "" {
|
||||
e.Label(opts.Label)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func iconMissing(c *ui.Context, name string, size float32, k theme.Tokens) *ui.Element {
|
||||
text := fmt.Sprintf(core.Msg(c, "icon.missing"), name)
|
||||
e := ui.Box(c).Size(size, size).Shrink(0).Role(ui.RoleImage).Label(text).Tooltip(text)
|
||||
logged := ui.Local(e, iconMissingKey{}, func() string { return "" })
|
||||
if *logged != name {
|
||||
*logged = name
|
||||
slog.Warn("mujica: icon missing", "name", name)
|
||||
}
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
in := ui.Rect{X: r.X + 1, Y: r.Y + 1, W: r.W - 2, H: r.H - 2}
|
||||
p.StrokeDashed(in, k.Danger, theme.ControlRadius, 1)
|
||||
p.Line(in.X+3, in.Y+3, in.X+in.W-3, in.Y+in.H-3, 1, k.Danger)
|
||||
})
|
||||
}
|
||||
|
||||
func iconColor(k theme.Tokens, t IconTone) (ui.Color, bool) {
|
||||
switch t {
|
||||
case IconInherit:
|
||||
return ui.Color{}, false
|
||||
case IconMuted:
|
||||
return k.TextMuted, true
|
||||
case IconAccent:
|
||||
return k.AccentText, true
|
||||
case IconSuccess:
|
||||
return k.Success, true
|
||||
case IconWarning:
|
||||
return k.Warning, true
|
||||
case IconDanger:
|
||||
return k.Danger, true
|
||||
case IconInfo:
|
||||
return k.Info, true
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown IconTone %d", t))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIconSizeAndLabel(t *testing.T) {
|
||||
var def, big ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
def = Icon(c, "star", IconOptions{}).Bounds()
|
||||
big = Icon(c, "bell", IconOptions{Size: 24, Tone: IconAccent, Label: "Alerts"}).Bounds()
|
||||
})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
if def.W != 16 || def.H != 16 || big.W != 24 || big.H != 24 {
|
||||
t.Fatalf("sizes %v %v", def, big)
|
||||
}
|
||||
if _, ok := tt.Find("Alerts"); !ok {
|
||||
t.Fatal("label not exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconMissingFallbackIsVisibleAndLogged(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
old := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
defer slog.SetDefault(old)
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Icon(c, "no-such-icon", IconOptions{Size: 20}).Bounds()
|
||||
})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if r.W != 20 || r.H != 20 {
|
||||
t.Fatalf("fallback size %v", r)
|
||||
}
|
||||
if _, ok := tt.Find("Missing icon: no-such-icon"); !ok {
|
||||
t.Fatal("fallback not labeled")
|
||||
}
|
||||
if n := strings.Count(buf.String(), "mujica: icon missing"); n != 1 {
|
||||
t.Fatalf("logged %d times: %s", n, buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "name=no-such-icon") {
|
||||
t.Fatalf("log lacks name: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconBadOptionsPanic(t *testing.T) {
|
||||
for name, opts := range map[string]IconOptions{"": {}, "star": {Size: -1}, "x": {Tone: IconTone(99)}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%q %+v: no panic", name, opts)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Icon(c, name, opts)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconSnapshot(t *testing.T) {
|
||||
snapshot(t, "icon", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(14).AlignItems(ui.Center).Children(func() {
|
||||
Icon(c, "star", IconOptions{})
|
||||
Icon(c, "star", IconOptions{Size: 20, Tone: IconMuted})
|
||||
Icon(c, "star", IconOptions{Size: 24, Tone: IconAccent})
|
||||
Icon(c, "circle-check", IconOptions{Size: 20, Tone: IconSuccess})
|
||||
Icon(c, "triangle-alert", IconOptions{Size: 20, Tone: IconWarning})
|
||||
Icon(c, "circle-x", IconOptions{Size: 20, Tone: IconDanger})
|
||||
Icon(c, "info", IconOptions{Size: 20, Tone: IconInfo})
|
||||
Icon(c, "no-such-icon", IconOptions{Size: 20})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ImageOptions configure an Image.
|
||||
type ImageOptions struct {
|
||||
// Width, Height in DIPs; zero uses src size or 160×120.
|
||||
Width, Height float32
|
||||
// Crop covers and crops the box; false fits inside.
|
||||
Crop bool
|
||||
// Alt describes the picture for assistive technology.
|
||||
Alt string
|
||||
// Loading shows the caller's load in progress.
|
||||
Loading bool
|
||||
// Err shows the caller's load failure with a retry button.
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImageView is a shown Image and this frame's events.
|
||||
type ImageView struct {
|
||||
Element *ui.Element
|
||||
// Retry reports the error state's retry button was pressed.
|
||||
Retry bool
|
||||
}
|
||||
|
||||
// imageBlank sizes the box of a nil src: 160×120.
|
||||
var imageBlank = ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120"/>`))
|
||||
|
||||
type imageRoomKey struct{}
|
||||
|
||||
// imageErrorTextWidth is the room the error text needs.
|
||||
const imageErrorTextWidth = 120
|
||||
|
||||
// Image shows a bitmap or SVG; nil shows a placeholder.
|
||||
func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView {
|
||||
if opts.Width < 0 || opts.Height < 0 {
|
||||
panic("mujica: Image Width and Height must not be negative")
|
||||
}
|
||||
loaded := opts.Err == nil && !opts.Loading && src != nil
|
||||
var v ImageView
|
||||
v.Element = imageSized(c, src, opts, func(box, pic *ui.Element) {
|
||||
if loaded {
|
||||
if opts.Alt != "" {
|
||||
pic.Label(opts.Alt)
|
||||
}
|
||||
return
|
||||
}
|
||||
pic.Invisible()
|
||||
v.Retry = imageLayers(c, box, opts)
|
||||
})
|
||||
return v
|
||||
}
|
||||
|
||||
// imageLayers builds the state layers over the hidden picture.
|
||||
func imageLayers(c *ui.Context, box *ui.Element, opts ImageOptions) (retry bool) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
room := box.Bounds()
|
||||
// Build again when the box changed, settling the error text.
|
||||
if last := ui.Local(box, imageRoomKey{}, func() ui.Rect { return ui.Rect{W: -1} }); *last != room {
|
||||
*last = room
|
||||
c.AnimationFrame()
|
||||
}
|
||||
{
|
||||
// The clipped backdrop; Retry sits above it, unclipped.
|
||||
back := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Clip().
|
||||
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
|
||||
switch {
|
||||
case opts.Err != nil:
|
||||
box.Role(ui.RoleGroup).Label(core.Msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
|
||||
ui.Row(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Gap(u * 2).Children(func() {
|
||||
if imageRetry(c, k, opts.Err.Error()).Clicked() {
|
||||
retry = true
|
||||
slog.Debug("mujica: image retry", "alt", opts.Alt)
|
||||
}
|
||||
if room.W >= theme.MinHitSize+u*4+imageErrorTextWidth && room.H >= theme.MinHitSize {
|
||||
ui.Text(c, core.Msg(c, "image.error")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Danger).SingleLine()
|
||||
}
|
||||
})
|
||||
case opts.Loading:
|
||||
box.Role(ui.RoleImage).Label(core.Msg(c, "image.loading")).Description(opts.Alt)
|
||||
back.Children(func() {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: max(8, min(20, room.H-4)), Label: core.Msg(c, "image.loading")})
|
||||
})
|
||||
default:
|
||||
box.Role(ui.RoleImage).Label(core.Msg(c, "image.placeholder")).Description(opts.Alt)
|
||||
back.Children(func() { Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted}) })
|
||||
}
|
||||
}
|
||||
return retry
|
||||
}
|
||||
|
||||
// imageSized builds the outer box and its picture, sized alike in every state.
|
||||
func imageSized(c *ui.Context, src ui.ImageSource, opts ImageOptions, layers func(box, pic *ui.Element)) *ui.Element {
|
||||
if src == nil {
|
||||
src = imageBlank
|
||||
}
|
||||
box := ui.Box(c)
|
||||
box.Children(func() {
|
||||
pic := ui.Image(c, src).Radius(theme.ControlRadius)
|
||||
if opts.Crop {
|
||||
pic.Fit(ui.Cover)
|
||||
}
|
||||
if opts.Width > 0 {
|
||||
box.Width(opts.Width)
|
||||
pic.Width(opts.Width)
|
||||
}
|
||||
if opts.Height > 0 {
|
||||
box.Height(opts.Height)
|
||||
pic.Height(opts.Height)
|
||||
}
|
||||
layers(box, pic)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
// imageRetry is the 28 DIP icon-only retry button.
|
||||
func imageRetry(c *ui.Context, k theme.Tokens, problem string) *ui.Element {
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Radius(theme.ControlRadius).
|
||||
Label(core.Msg(c, "image.retry")).Description(problem).Tooltip(problem)
|
||||
bg := k.Surface
|
||||
if b.Pressed() {
|
||||
bg = k.Selection
|
||||
} else if b.Hovered() {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
b.Background(bg).Border(theme.BorderWidth, k.ControlBorder).TextColor(k.Danger)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
b.Transition(core.ColorFade)
|
||||
b.Children(func() { Icon(c, "refresh-cw", IconOptions{Size: 16}) })
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestImageErrorTextNeedsRoom(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
w float32
|
||||
text bool
|
||||
}{{200, true}, {80, false}} {
|
||||
ready := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if ready {
|
||||
Image(c, nil, ImageOptions{Width: tc.w, Height: 60, Err: errors.New("404")})
|
||||
}
|
||||
}, 300, 200)
|
||||
ready = true
|
||||
tt.Frame()
|
||||
n := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s == "Image failed to load" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// One is the box's name; a second is the visible text.
|
||||
if got := n == 2; got != tc.text {
|
||||
t.Errorf("width %v: error text shown %v, want %v (%v)", tc.w, got, tc.text, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// imageStateBoxes returns the box of loaded, loading and error states.
|
||||
func imageStateBoxes(t *testing.T, opts ImageOptions, parent func(c *ui.Context, fn func())) []ui.Rect {
|
||||
t.Helper()
|
||||
bmp := imageTestBitmap()
|
||||
var got []ui.Rect
|
||||
for _, state := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
|
||||
o := opts
|
||||
o.Loading, o.Err = state.Loading, state.Err
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
parent(c, func() { r = Image(c, bmp, o).Element.Bounds() })
|
||||
}, 400, 400)
|
||||
tt.Frame()
|
||||
got = append(got, r)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestImageStatesShareBoxInColumn(t *testing.T) {
|
||||
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
|
||||
ui.Column(c).Width(200).Children(fn)
|
||||
})
|
||||
if got[0].W != 200 || got[0].H != 100 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStatesShareBoxInSizedParent(t *testing.T) {
|
||||
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
|
||||
ui.Column(c).Size(120, 60).Children(fn)
|
||||
})
|
||||
if got[0].W != 120 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStatesShareExplicitSize(t *testing.T) {
|
||||
got := imageStateBoxes(t, ImageOptions{Width: 120, Height: 60}, func(c *ui.Context, fn func()) {
|
||||
ui.Column(c).Width(300).Children(fn)
|
||||
})
|
||||
if got[0].W != 120 || got[0].H != 60 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStatesShareBoxInNarrowRow(t *testing.T) {
|
||||
got := imageStateBoxes(t, ImageOptions{}, func(c *ui.Context, fn func()) {
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(fn)
|
||||
})
|
||||
if got[0].W != 40 || got[0].H != 20 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRetryUnclippedInSmallBox(t *testing.T) {
|
||||
retries := 0
|
||||
var box ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Row(c).Padding(40).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
|
||||
v := Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")})
|
||||
box = v.Element.Bounds()
|
||||
if v.Retry {
|
||||
retries++
|
||||
}
|
||||
})
|
||||
})
|
||||
}, 300, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if box.W != 40 || box.H != 20 {
|
||||
t.Fatalf("box %v", box)
|
||||
}
|
||||
r, ok := tt.Find("Retry")
|
||||
if !ok || r.W != 28 || r.H != 28 {
|
||||
t.Fatalf("retry bounds %v", r)
|
||||
}
|
||||
tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d, want 1", retries)
|
||||
}
|
||||
cx := int(r.X + r.W/2)
|
||||
ring := func() (above, below ui.Color) {
|
||||
img := tt.Image()
|
||||
a := img.RGBAAt(cx, int(r.Y-theme.FocusGap-theme.FocusWidth/2))
|
||||
b := img.RGBAAt(cx, int(r.Y+r.H+theme.FocusGap+theme.FocusWidth/2))
|
||||
return ui.Color{R: a.R, G: a.G, B: a.B, A: a.A}, ui.Color{R: b.R, G: b.G, B: b.B, A: b.A}
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Retry") {
|
||||
t.Fatal("retry not focused")
|
||||
}
|
||||
above, below := ring()
|
||||
want := theme.Light().Focus
|
||||
if above != want || below != want {
|
||||
t.Fatalf("ring above %v below %v, want %v", above, below, want)
|
||||
}
|
||||
if r.Y-theme.FocusGap >= box.Y || r.Y+r.H+theme.FocusGap <= box.Y+box.H {
|
||||
t.Fatalf("ring rows not outside box %v (retry %v)", box, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func imageTestBitmap() *ui.Bitmap {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 80, 40))
|
||||
for y := range 40 {
|
||||
for x := range 80 {
|
||||
img.Set(x, y, color.RGBA{uint8(100 + x), 32, 52, 255})
|
||||
}
|
||||
}
|
||||
return ui.NewBitmap(img)
|
||||
}
|
||||
|
||||
func TestImageLoadedSizes(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
var natural, sized ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
natural = Image(c, bmp, ImageOptions{Alt: "Gallery"}).Element.Bounds()
|
||||
sized = Image(c, bmp, ImageOptions{Width: 50, Height: 50, Crop: true}).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if natural.W != 80 || natural.H != 40 {
|
||||
t.Fatalf("natural %v", natural)
|
||||
}
|
||||
if sized.W != 50 || sized.H != 50 {
|
||||
t.Fatalf("sized %v", sized)
|
||||
}
|
||||
if _, ok := tt.Find("Gallery"); !ok {
|
||||
t.Fatal("alt text not exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStates(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
src ui.ImageSource
|
||||
opts ImageOptions
|
||||
label string
|
||||
}{
|
||||
{"placeholder", nil, ImageOptions{}, "No image"},
|
||||
{"loading", nil, ImageOptions{Loading: true}, "Loading image"},
|
||||
{"error", nil, ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"},
|
||||
} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Image(c, tc.src, tc.opts).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if r.W != 160 || r.H != 120 {
|
||||
t.Errorf("%s: size %v", tc.name, r)
|
||||
}
|
||||
if _, ok := tt.Find(tc.label); !ok {
|
||||
t.Errorf("%s: no %q", tc.name, tc.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRetry(t *testing.T) {
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if Image(c, nil, ImageOptions{Width: 200, Height: 140, Err: errors.New("timeout")}).Retry {
|
||||
retries++
|
||||
}
|
||||
}, 300, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Retry") {
|
||||
t.Fatal("retry not focused by Tab")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retries != 2 {
|
||||
t.Fatalf("retries %d, want 2", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageNegativeSizePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Image(c, nil, ImageOptions{Width: -1})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestImageSnapshot(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
snapshot(t, "image", 820, 170, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Image(c, bmp, ImageOptions{Width: 120, Height: 120, Crop: true, Alt: "Cropped"})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120})
|
||||
Image(c, nil, ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
Image(c, nil, ImageOptions{Width: 200, Height: 120, Err: errors.New("404")})
|
||||
Image(c, bmp, ImageOptions{Err: errors.New("404")})
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Margin(20, 0, 0, 0).Children(func() {
|
||||
Image(c, bmp, ImageOptions{Err: errors.New("404")})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageKeepsSizeWhileLoading(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
for _, tc := range []struct {
|
||||
opts ImageOptions
|
||||
w, h float32
|
||||
}{
|
||||
{ImageOptions{Loading: true}, 80, 40},
|
||||
{ImageOptions{Width: 160, Loading: true}, 160, 80},
|
||||
{ImageOptions{Err: errors.New("x")}, 80, 40},
|
||||
} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Image(c, bmp, tc.opts).Element.Bounds()
|
||||
})
|
||||
}, 400, 400)
|
||||
tt.Frame()
|
||||
if r.W != tc.w || r.H != tc.h {
|
||||
t.Errorf("%+v: %v, want %vx%v", tc.opts, r, tc.w, tc.h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageStatesShareBoxWhenShrunk(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
var got []ui.Rect
|
||||
for _, opts := range []ImageOptions{{}, {Loading: true}, {Err: errors.New("x")}} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
|
||||
r = Image(c, bmp, opts).Element.Bounds()
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
got = append(got, r)
|
||||
}
|
||||
if got[0].W != 40 || got[0].H != 20 || got[1] != got[0] || got[2] != got[0] {
|
||||
t.Fatalf("loaded, loading, error boxes %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRetryInSmallBox(t *testing.T) {
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Width(40).AlignItems(ui.Start).Children(func() {
|
||||
if Image(c, imageTestBitmap(), ImageOptions{Err: errors.New("timeout")}).Retry {
|
||||
retries++
|
||||
}
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Frame()
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d, want 1", retries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// Kbd shows keys as caps; "Mod" is Command or Ctrl.
|
||||
func Kbd(c *ui.Context, keys ...string) *ui.Element {
|
||||
if len(keys) == 0 {
|
||||
panic("mujica: Kbd needs at least one key")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
mac := runtime.GOOS == "darwin"
|
||||
spoken := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
if key == "" {
|
||||
panic("mujica: Kbd key must not be empty")
|
||||
}
|
||||
_, spoken[i] = KbdKey(key, mac)
|
||||
}
|
||||
row := ui.Row(c).Gap(2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleText).Label(strings.Join(spoken, "+"))
|
||||
row.Children(func() {
|
||||
for i, key := range keys {
|
||||
if i > 0 && !mac {
|
||||
ui.Text(c, "+").FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
face, _ := KbdKey(key, mac)
|
||||
ui.Box(c).Key(i).Row().Center().MinWidth(core.FontSize(c, 20)).MinHeight(core.FontSize(c, 20)).Padding(0, 5).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).BorderColor(k.ControlBorder).
|
||||
BorderWidth(1, 1, 2, 1).Children(func() {
|
||||
ui.Text(c, face).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// kbdNames maps keys to macOS cap, word, other cap.
|
||||
var kbdNames = map[string]struct{ macCap, macWord, other string }{
|
||||
"mod": {"⌘", "Command", "Ctrl"},
|
||||
"ctrl": {"⌃", "Control", "Ctrl"},
|
||||
"alt": {"⌥", "Option", "Alt"},
|
||||
"shift": {"⇧", "Shift", "Shift"},
|
||||
"enter": {"↩", "Return", "Enter"},
|
||||
"esc": {"esc", "Escape", "Esc"},
|
||||
"tab": {"⇥", "Tab", "Tab"},
|
||||
"backspace": {"⌫", "Delete", "Backspace"},
|
||||
"delete": {"⌦", "Forward Delete", "Delete"},
|
||||
"up": {"↑", "Up", "↑"},
|
||||
"down": {"↓", "Down", "↓"},
|
||||
"left": {"←", "Left", "←"},
|
||||
"right": {"→", "Right", "→"},
|
||||
}
|
||||
|
||||
// KbdKey returns how key shows and reads on the platform.
|
||||
func KbdKey(key string, mac bool) (shown, spoken string) {
|
||||
n, ok := kbdNames[strings.ToLower(key)]
|
||||
if !ok {
|
||||
if len([]rune(key)) == 1 {
|
||||
key = strings.ToUpper(key)
|
||||
}
|
||||
return key, key
|
||||
}
|
||||
if mac {
|
||||
return n.macCap, n.macWord
|
||||
}
|
||||
word := n.other
|
||||
switch strings.ToLower(key) {
|
||||
case "up", "down", "left", "right":
|
||||
word = n.macWord
|
||||
}
|
||||
return n.other, word
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestKbdKeyPlatforms(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
mac bool
|
||||
shown, spoken string
|
||||
}{
|
||||
{"Mod", true, "⌘", "Command"},
|
||||
{"Mod", false, "Ctrl", "Ctrl"},
|
||||
{"shift", true, "⇧", "Shift"},
|
||||
{"Alt", false, "Alt", "Alt"},
|
||||
{"Alt", true, "⌥", "Option"},
|
||||
{"Up", false, "↑", "Up"},
|
||||
{"k", false, "K", "K"},
|
||||
{"F5", true, "F5", "F5"},
|
||||
} {
|
||||
shown, spoken := KbdKey(tc.key, tc.mac)
|
||||
if shown != tc.shown || spoken != tc.spoken {
|
||||
t.Errorf("%q mac=%v: %q %q", tc.key, tc.mac, shown, spoken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdShowsPlatformCombo(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Kbd(c, "Mod", "Shift", "p")
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
spoken, mod := "Ctrl+Shift+P", "Ctrl"
|
||||
if runtime.GOOS == "darwin" {
|
||||
spoken, mod = "Command+Shift+P", "⌘"
|
||||
}
|
||||
if _, ok := tt.Find(spoken); !ok {
|
||||
t.Fatalf("not read as %q", spoken)
|
||||
}
|
||||
if !tt.HasText(mod) || !tt.HasText("P") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused(spoken) {
|
||||
t.Fatal("Kbd took focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdEmptyPanics(t *testing.T) {
|
||||
for _, keys := range [][]string{nil, {"Mod", ""}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%q: no panic", keys)
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Kbd(c, keys...)
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestKbdSnapshot(t *testing.T) {
|
||||
snapshot(t, "kbd", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(16).AlignItems(ui.Center).Children(func() {
|
||||
Kbd(c, "Mod", "K")
|
||||
Kbd(c, "Mod", "Shift", "P")
|
||||
Kbd(c, "Esc")
|
||||
Kbd(c, "Alt", "Up")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestKbdTextScale(t *testing.T) {
|
||||
var h [2]float32
|
||||
for i, s := range []float32{1, 2} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
h[i] = Kbd(c, "Mod", "K").Bounds().H
|
||||
})
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: s})
|
||||
tt.Frame()
|
||||
}
|
||||
if h[1] < h[0]*1.5 {
|
||||
t.Fatalf("height %v at 2x, %v at 1x", h[1], h[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LinkOptions configure a Link; empty URL means in-app action.
|
||||
type LinkOptions struct {
|
||||
// URL opens in the browser; paths go to the router.
|
||||
URL string
|
||||
// Error is the caller's open failure, shown below the link.
|
||||
Error string
|
||||
}
|
||||
|
||||
// Link is underlined text; malformed URLs show an error instead.
|
||||
func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element {
|
||||
if label == "" {
|
||||
panic("mujica: Link needs a label")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
external := false
|
||||
problem := opts.Error
|
||||
target := opts.URL
|
||||
if opts.URL != "" {
|
||||
ok := false
|
||||
external, ok = linkCheck(opts.URL)
|
||||
if !ok {
|
||||
external, target = false, ""
|
||||
if problem == "" {
|
||||
problem = fmt.Sprintf(core.Msg(c, "link.invalid"), opts.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
var l *ui.Element
|
||||
ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
l = ui.Link(c, label, target).Underline().DecorationThickness(1)
|
||||
internal.Ring(l, k.Focus, theme.ControlRadius)
|
||||
color := k.AccentText
|
||||
if l.Pressed() || l.Hovered() {
|
||||
color = k.Text
|
||||
}
|
||||
l.TextColor(color).DecorationColor(color)
|
||||
if external {
|
||||
Icon(c, "external-link", IconOptions{Size: 12, Tone: IconAccent})
|
||||
}
|
||||
})
|
||||
if problem != "" {
|
||||
l.Error(problem)
|
||||
ui.Text(c, problem).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
if l.Clicked() {
|
||||
slog.Debug("mujica: link activate", "url", opts.URL, "opened", target != "")
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// linkCheck reports whether raw has a scheme and is openable.
|
||||
func linkCheck(raw string) (external, ok bool) {
|
||||
p, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
switch p.Scheme {
|
||||
case "":
|
||||
return false, p.Path != "" || p.RawQuery != ""
|
||||
case "http", "https":
|
||||
return true, p.Host != ""
|
||||
}
|
||||
return true, p.Opaque != "" || p.Host != "" || p.Path != ""
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestLinkOpensURL(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Link(c, "Charter", LinkOptions{URL: "https://example.com/charter"})
|
||||
}, 300, 100)
|
||||
if err := tt.Click("Charter"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
want := []string{"https://example.com/charter", "https://example.com/charter"}
|
||||
if got := tt.OpenedURLs(); !slices.Equal(got, want) {
|
||||
t.Fatalf("opened %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkAction(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if Link(c, "Show details", LinkOptions{}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 300, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != 1 || len(tt.OpenedURLs()) != 0 {
|
||||
t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkInvalidURLIsNotOpened(t *testing.T) {
|
||||
n := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if Link(c, "Broken", LinkOptions{URL: "https://"}).Clicked() {
|
||||
n++
|
||||
}
|
||||
}, 400, 100)
|
||||
if err := tt.Click("Broken"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 || len(tt.OpenedURLs()) != 0 {
|
||||
t.Fatalf("clicks %d, opened %v", n, tt.OpenedURLs())
|
||||
}
|
||||
if !tt.HasText("This link cannot be opened: https://") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkCallerError(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
Link(c, "手册", LinkOptions{URL: "https://example.com", Error: "没有可以打开此链接的应用"})
|
||||
}, 400, 100)
|
||||
tt.Frame()
|
||||
if !tt.HasText("没有可以打开此链接的应用") {
|
||||
t.Fatalf("texts %v", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkCheck(t *testing.T) {
|
||||
for raw, want := range map[string][2]bool{
|
||||
"https://example.com": {true, true},
|
||||
"https://": {true, false},
|
||||
"mailto:a@b.c": {true, true},
|
||||
"/notes/42": {false, true},
|
||||
"?tab=info": {false, true},
|
||||
"http://[::1": {false, false},
|
||||
} {
|
||||
ext, ok := linkCheck(raw)
|
||||
if ext != want[0] || ok != want[1] {
|
||||
t.Errorf("%q: external %v ok %v", raw, ext, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkSnapshot(t *testing.T) {
|
||||
snapshot(t, "link", 420, 140, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
Link(c, "Read the court charter", LinkOptions{URL: "https://example.com"})
|
||||
Link(c, "Show the guest list", LinkOptions{})
|
||||
Link(c, "Broken link", LinkOptions{URL: "https://"})
|
||||
})
|
||||
})
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TagOptions configure a Tag; zero is a plain label.
|
||||
type TagOptions struct {
|
||||
// Icon names an icon shown before the label.
|
||||
Icon string
|
||||
// Clickable makes the label a button reported by TagView.Clicked.
|
||||
Clickable bool
|
||||
// Closable adds a remove button reported by TagView.Closed.
|
||||
Closable bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// TagView is a shown Tag and this frame's events.
|
||||
type TagView struct {
|
||||
Element *ui.Element
|
||||
// Clicked reports the label was activated, not the remove button.
|
||||
Clicked bool
|
||||
// Closed reports the remove button, Delete or Backspace.
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Tag shows a label; click and remove are separate stops.
|
||||
func Tag(c *ui.Context, label string, opts TagOptions) TagView {
|
||||
if label == "" {
|
||||
panic("mujica: Tag needs a label")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var v TagView
|
||||
box := ui.Row(c).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border).Shrink(0).Disabled(opts.Disabled)
|
||||
v.Element = box
|
||||
box.Children(func() {
|
||||
var main *ui.Element
|
||||
if opts.Clickable {
|
||||
main = ui.ButtonBase(c).Label(label)
|
||||
internal.Ring(main, k.Focus, theme.ControlRadius)
|
||||
} else {
|
||||
main = ui.Row(c).AlignItems(ui.Center)
|
||||
}
|
||||
main.Gap(u*1.5).Padding(0, u*2).FillHeight().Radius(theme.ControlRadius).TextColor(k.Text)
|
||||
if opts.Clickable && !opts.Disabled {
|
||||
if main.Pressed() {
|
||||
main.Background(k.Selection)
|
||||
} else if main.Hovered() {
|
||||
main.Background(k.SurfaceHover)
|
||||
}
|
||||
main.Transition(core.ColorFade)
|
||||
}
|
||||
if opts.Closable {
|
||||
main.Padding(0, u, 0, u*2)
|
||||
}
|
||||
main.Children(func() {
|
||||
if opts.Icon != "" {
|
||||
Icon(c, opts.Icon, IconOptions{Size: 14})
|
||||
}
|
||||
ui.Text(c, label).FontSize(core.FontSize(c, theme.BodySize-1)).SingleLine()
|
||||
})
|
||||
if opts.Clickable && main.Clicked() {
|
||||
v.Clicked = true
|
||||
}
|
||||
if opts.Closable {
|
||||
if opts.Clickable && !main.IsDisabled() && (main.Shortcut(0, ui.KeyDelete) || main.Shortcut(0, ui.KeyBackspace)) {
|
||||
v.Closed = true
|
||||
}
|
||||
if tagClose(c, k, label).Clicked() {
|
||||
v.Closed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if v.Clicked {
|
||||
slog.Debug("mujica: tag click", "label", label)
|
||||
}
|
||||
if v.Closed {
|
||||
slog.Debug("mujica: tag close", "label", label)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func tagClose(c *ui.Context, k theme.Tokens, label string) *ui.Element {
|
||||
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).
|
||||
Radius(theme.ControlRadius).Label(fmt.Sprintf(core.Msg(c, "tag.remove"), label))
|
||||
fg := k.TextMuted
|
||||
if b.Pressed() {
|
||||
b.Background(k.Selection)
|
||||
fg = k.Text
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
b.Transition(core.ColorFade)
|
||||
b.TextColor(fg).Children(func() {
|
||||
Icon(c, "x", IconOptions{Size: 12})
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type tagCounts struct{ clicked, closed int }
|
||||
|
||||
func tagTester(opts TagOptions, n *tagCounts) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
v := Tag(c, "Velvet", opts)
|
||||
if v.Clicked {
|
||||
n.clicked++
|
||||
}
|
||||
if v.Closed {
|
||||
n.closed++
|
||||
}
|
||||
})
|
||||
}, 300, 100)
|
||||
}
|
||||
|
||||
func TestTagClickAndCloseAreSeparate(t *testing.T) {
|
||||
var n tagCounts
|
||||
tt := tagTester(TagOptions{Clickable: true, Closable: true, Icon: "star"}, &n)
|
||||
if err := tt.Click("Remove Velvet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != (tagCounts{0, 1}) {
|
||||
t.Fatalf("after close click %+v", n)
|
||||
}
|
||||
if err := tt.Click("Velvet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != (tagCounts{1, 1}) {
|
||||
t.Fatalf("after label click %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagKeyboard(t *testing.T) {
|
||||
var n tagCounts
|
||||
tt := tagTester(TagOptions{Clickable: true, Closable: true}, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Remove Velvet") {
|
||||
t.Fatal("remove button not second focus stop")
|
||||
}
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if n != (tagCounts{1, 2}) {
|
||||
t.Fatalf("counts %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagPlainTakesNoFocus(t *testing.T) {
|
||||
var n tagCounts
|
||||
tt := tagTester(TagOptions{}, &n)
|
||||
_ = tt.Click("Velvet")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if n != (tagCounts{}) || tt.Focused("Velvet") {
|
||||
t.Fatalf("plain tag reacted: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagDisabled(t *testing.T) {
|
||||
var n tagCounts
|
||||
tt := tagTester(TagOptions{Clickable: true, Closable: true, Disabled: true}, &n)
|
||||
_ = tt.Click("Velvet")
|
||||
_ = tt.Click("Remove Velvet")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if n != (tagCounts{}) {
|
||||
t.Fatalf("disabled tag reacted: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagSnapshot(t *testing.T) {
|
||||
snapshot(t, "tag", 480, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(24).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
Tag(c, "Baroque", TagOptions{})
|
||||
Tag(c, "Favorite", TagOptions{Icon: "star", Clickable: true})
|
||||
Tag(c, "Archive", TagOptions{Closable: true})
|
||||
Tag(c, "Locked", TagOptions{Icon: "lock", Closable: true, Disabled: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagDisabledAncestorBlocksDelete(t *testing.T) {
|
||||
closed, off := 0, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Disabled(off).Children(func() {
|
||||
if Tag(c, "Velvet", TagOptions{Clickable: true, Closable: true}).Closed {
|
||||
closed++
|
||||
}
|
||||
})
|
||||
}, 300, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
off = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if closed != 0 {
|
||||
t.Fatalf("closed %d times under a disabled ancestor", closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagTextScale(t *testing.T) {
|
||||
var h [2]float32
|
||||
for i, s := range []float32{1, 2} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).AlignItems(ui.Start).Children(func() {
|
||||
h[i] = Tag(c, "Velvet", TagOptions{}).Element.Bounds().W
|
||||
})
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: s})
|
||||
tt.Frame()
|
||||
}
|
||||
if h[1] < h[0]*1.5 {
|
||||
t.Fatalf("width %v at 2x, %v at 1x", h[1], h[0])
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// TextOptions configure a Text. The zero value wraps every line.
|
||||
type TextOptions struct {
|
||||
// MaxLines cuts with an ellipsis after n lines; zero never.
|
||||
MaxLines int
|
||||
// Selectable lets the user select and copy the text.
|
||||
Selectable bool
|
||||
}
|
||||
|
||||
// Text shows body text wrapping at its width.
|
||||
func Text(c *ui.Context, s string, opts TextOptions) *ui.Element {
|
||||
if opts.MaxLines < 0 {
|
||||
panic("mujica: Text MaxLines must not be negative")
|
||||
}
|
||||
e := ui.Text(c, s).LineHeight(theme.LineHeight)
|
||||
switch {
|
||||
case opts.MaxLines == 1:
|
||||
e.SingleLine()
|
||||
case opts.MaxLines > 1:
|
||||
e.MaxLines(opts.MaxLines)
|
||||
}
|
||||
if opts.Selectable {
|
||||
e.Selectable().Focusable()
|
||||
internal.Ring(e, core.Tokens(c).Focus, theme.ControlRadius)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// EditableTextOptions configure the editable mode of Text.
|
||||
type EditableTextOptions struct {
|
||||
// Saving shows the caller's save in progress and blocks editing.
|
||||
Saving bool
|
||||
// Error is the caller's save failure, shown below the text.
|
||||
Error string
|
||||
// Label names the text for assistive technology.
|
||||
Label string
|
||||
}
|
||||
|
||||
// EditableTextView is a shown EditableText and this frame's outcome.
|
||||
type EditableTextView struct {
|
||||
Element *ui.Element
|
||||
// Canceled reports Escape discarded an edit.
|
||||
Canceled bool
|
||||
changed bool
|
||||
}
|
||||
|
||||
// Changed reports a confirmed new value, already in *value.
|
||||
func (v EditableTextView) Changed() bool { return v.changed }
|
||||
|
||||
type editableTextState struct {
|
||||
editing, fresh bool
|
||||
draft string
|
||||
}
|
||||
|
||||
type editableTextKey struct{}
|
||||
|
||||
// EditableText is Text's editable mode with confirm and cancel.
|
||||
func EditableText(c *ui.Context, value *string, opts EditableTextOptions) EditableTextView {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
line := k.ControlBorder
|
||||
if opts.Error != "" {
|
||||
line = k.Danger
|
||||
}
|
||||
var v EditableTextView
|
||||
ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
v = editableTextField(c, k, value, opts, line)
|
||||
if opts.Saving {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 12, Label: core.Msg(c, "editable_text.saving")})
|
||||
}
|
||||
})
|
||||
if opts.Error != "" {
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case v.changed:
|
||||
slog.Debug("mujica: editabletext changed", "len", len(*value))
|
||||
case v.Canceled:
|
||||
slog.Debug("mujica: editabletext canceled")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func editableTextField(c *ui.Context, k theme.Tokens, value *string, opts EditableTextOptions, line ui.Color) EditableTextView {
|
||||
pad := core.Density(c).Unit() / 2
|
||||
box := ui.Row(c).AlignItems(ui.Center).MinWidth(theme.MinHitSize * 2).MinHeight(theme.MinHitSize).
|
||||
Radius(theme.ControlRadius).Disabled(opts.Saving)
|
||||
v := EditableTextView{Element: box}
|
||||
st := ui.Local(box, editableTextKey{}, func() editableTextState { return editableTextState{} })
|
||||
if opts.Saving {
|
||||
st.editing = false
|
||||
}
|
||||
if !st.editing {
|
||||
box.Focusable().Padding(pad+1, pad*2+1).BorderWidth(0, 0, 1, 0).BorderColor(line).BorderStyle(ui.BorderDashed).
|
||||
Label(opts.Label).Error(opts.Error)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
if box.Hovered() {
|
||||
box.Background(k.SurfaceHover)
|
||||
}
|
||||
box.Transition(core.ColorFade)
|
||||
if !box.IsDisabled() && (box.DoubleClicked() || box.Shortcut(0, ui.KeyEnter)) {
|
||||
st.editing, st.fresh, st.draft = true, true, *value
|
||||
} else {
|
||||
box.Children(func() { ui.Text(c, *value).SingleLine() })
|
||||
return v
|
||||
}
|
||||
}
|
||||
var in *ui.Element
|
||||
box.Children(func() {
|
||||
in = ui.TextInputBase(c, &st.draft).Grow(1).MinWidth(theme.MinHitSize*3).Padding(pad, pad*2).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Accent).TextColor(k.Text).
|
||||
Label(opts.Label).Error(opts.Error)
|
||||
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||
})
|
||||
done := func() {
|
||||
st.editing = false
|
||||
box.Focus()
|
||||
}
|
||||
switch {
|
||||
case st.fresh:
|
||||
st.fresh = false
|
||||
in.Focus()
|
||||
case in.Submitted():
|
||||
v.changed = st.draft != *value
|
||||
*value = st.draft
|
||||
done()
|
||||
case in.Shortcut(0, ui.KeyEscape):
|
||||
v.Canceled = true
|
||||
done()
|
||||
case !in.Focused():
|
||||
v.changed = st.draft != *value
|
||||
*value = st.draft
|
||||
st.editing = false
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestTextSelectAndCopy(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Text(c, "ID-4821", TextOptions{Selectable: true})
|
||||
}, 300, 100)
|
||||
if err := tt.Click("ID-4821"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if got := tt.Clipboard(); got != "ID-4821" {
|
||||
t.Fatalf("clipboard %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextSelectableTakesTab(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Text(c, "ID-4821", TextOptions{Selectable: true})
|
||||
}, 300, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("ID-4821") {
|
||||
t.Fatal("selectable text not in Tab order")
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if got := tt.Clipboard(); got != "ID-4821" {
|
||||
t.Fatalf("clipboard %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextBlurConfirms(t *testing.T) {
|
||||
name := "a"
|
||||
changed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{}).Changed() {
|
||||
changed++
|
||||
}
|
||||
input.Button(c, "Other", input.ButtonOptions{})
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("b")
|
||||
if err := tt.Click("Other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "ab" || changed != 1 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNotSelectableByDefault(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Text(c, "plain", TextOptions{})
|
||||
}, 300, 100)
|
||||
_ = tt.Click("plain")
|
||||
tt.Command("selectAll")
|
||||
tt.Command("copy")
|
||||
if got := tt.Clipboard(); got != "" {
|
||||
t.Fatalf("clipboard %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextMaxLinesCuts(t *testing.T) {
|
||||
long := "The court gathers in the hall of mirrors while candles burn low and the music fades away."
|
||||
var free, cut float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Width(120).Children(func() {
|
||||
free = Text(c, long, TextOptions{}).Bounds().H
|
||||
cut = Text(c, long, TextOptions{MaxLines: 1}).Bounds().H
|
||||
})
|
||||
}, 300, 400)
|
||||
tt.Frame()
|
||||
if cut <= 0 || free < cut*3 {
|
||||
t.Fatalf("free %v, one line %v", free, cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextNegativeMaxLinesPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
Text(c, "x", TextOptions{MaxLines: -1})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestEditableTextConfirm(t *testing.T) {
|
||||
name := "report.pdf"
|
||||
changed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{Label: "File name"}).Changed() {
|
||||
changed++
|
||||
}
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.Focused("File name") {
|
||||
t.Fatal("editing input not named File name")
|
||||
}
|
||||
tt.Command("selectAll")
|
||||
tt.Type("summary.pdf")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "summary.pdf" || changed != 1 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
if !tt.Focused("File name") {
|
||||
t.Fatal("focus not back on the text after confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextCancel(t *testing.T) {
|
||||
name := "report.pdf"
|
||||
changed, canceled := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
v := EditableText(c, &name, EditableTextOptions{})
|
||||
if v.Changed() {
|
||||
changed++
|
||||
}
|
||||
if v.Canceled {
|
||||
canceled++
|
||||
}
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("draft")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if name != "report.pdf" || changed != 0 || canceled != 1 {
|
||||
t.Fatalf("name %q, changed %d, canceled %d", name, changed, canceled)
|
||||
}
|
||||
if !tt.HasText("report.pdf") {
|
||||
t.Fatal("text not shown after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextSavingBlocksAndErrorShows(t *testing.T) {
|
||||
name := "notes"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
EditableText(c, &name, EditableTextOptions{Saving: true, Error: "disk full"})
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("x")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "notes" {
|
||||
t.Fatalf("edited while saving: %q", name)
|
||||
}
|
||||
if !tt.HasText("disk full") {
|
||||
t.Fatal("save error not shown")
|
||||
}
|
||||
if _, ok := tt.Find("Saving"); !ok {
|
||||
t.Fatal("saving spinner not labeled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextSnapshot(t *testing.T) {
|
||||
name := "Coronation.md"
|
||||
snapshot(t, "text", 420, 280, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).Width(380).Children(func() {
|
||||
Text(c, "Velvet curtains fall over the long gallery, and every candle leans toward the open door.", TextOptions{})
|
||||
Text(c, "A single line that is far too long to fit and so ends in an ellipsis.", TextOptions{MaxLines: 1})
|
||||
Text(c, "Selectable: ID-4821", TextOptions{Selectable: true})
|
||||
EditableText(c, &name, EditableTextOptions{Error: "The name is already taken."})
|
||||
EditableText(c, &name, EditableTextOptions{Saving: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestEditableTextInputFocusRing(t *testing.T) {
|
||||
name := "report.pdf"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Padding(20).Gap(20).AlignItems(ui.Start).Children(func() {
|
||||
EditableText(c, &name, EditableTextOptions{Label: "File name"})
|
||||
input.Button(c, "Other", input.ButtonOptions{})
|
||||
})
|
||||
}, 300, 160)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
r, ok := tt.Find("File name")
|
||||
if !ok || !tt.Focused("File name") {
|
||||
t.Fatal("editing input not found or not focused")
|
||||
}
|
||||
// The ring's middle: focus gap plus half its width outside the box.
|
||||
x, y := int(r.X-theme.FocusGap-theme.FocusWidth/2), int(r.Y+r.H/2)
|
||||
focused := tt.Image().RGBAAt(x, y)
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("File name") {
|
||||
t.Fatal("focus did not leave")
|
||||
}
|
||||
plain := tt.Image().RGBAAt(x, y)
|
||||
want := theme.Light().Focus
|
||||
if d := int(focused.R) - int(want.R); d < -24 || d > 24 || focused == plain {
|
||||
t.Fatalf("ring pixel %v focused, %v unfocused, want near %v", focused, plain, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user