Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+82
View File
@@ -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
}
+131
View File
@@ -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()
})
}
})
})
}
+106
View File
@@ -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)
}
}
+64
View File
@@ -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})
})
})
}
+144
View File
@@ -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))
}
+133
View File
@@ -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])
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package display holds MujicaUI text, headings, icons, images, avatars and other read-only display.
package display
+137
View File
@@ -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)
}
+52
View File
@@ -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")
}
+80
View File
@@ -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: "第三章"})
})
})
}
+24
View File
@@ -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})
}
+98
View File
@@ -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))
}
+90
View File
@@ -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})
})
})
}
+140
View File
@@ -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
}
+141
View File
@@ -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)
}
}
+184
View File
@@ -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)
}
}
+79
View File
@@ -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
}
+99
View File
@@ -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])
}
}
+81
View File
@@ -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 != ""
}
+97
View File
@@ -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
View File
@@ -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
}
+131
View File
@@ -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
View File
@@ -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
}
+220
View File
@@ -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)
}
}