Merge branch 'worktree-agent-a1dd614394ee20ff2'
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AvatarOptions configure an Avatar.
|
||||
type AvatarOptions struct {
|
||||
// Image is the picture; nil falls back to initials, then a person icon.
|
||||
Image *ui.Bitmap
|
||||
// Size is the diameter in DIPs; zero means 32.
|
||||
Size float32
|
||||
}
|
||||
|
||||
// Avatar shows a person as a round picture, their initials or an icon.
|
||||
// Assistive technology reads it as an image named name.
|
||||
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 := Tokens(c)
|
||||
label := name
|
||||
if label == "" {
|
||||
label = 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 the first letters of the 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,128 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"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 is how many 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 shows overlapping avatars. Past Max, a "+N" button opens a
|
||||
// popover with the full list of names.
|
||||
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 := 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, size, ring, rest, len(items))
|
||||
if more.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func avatarGroupMore(c *ui.Context, k theme.Tokens, size, ring float32, rest, total int) *ui.Element {
|
||||
cell := ui.Box(c).Key("more").Padding(ring).Radius(size/2+ring).Background(k.Background).
|
||||
Margin(0, 0, 0, -size/4).Shrink(0)
|
||||
var b *ui.Element
|
||||
cell.Children(func() {
|
||||
b = ui.ButtonBase(c).Size(size, size).Radius(size / 2).Label(fmt.Sprintf(msg(c, "avatar_group.more"), total))
|
||||
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.Text)
|
||||
internal.Ring(b, k.Focus, size/2)
|
||||
internal.Fade(b, Motion(c, theme.StateDuration))
|
||||
b.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 := 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 := 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,90 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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 mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"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. The zero value lays it out 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 with no Text hides the badge.
|
||||
Count int
|
||||
// Max caps the shown count as "Max+"; zero means 99.
|
||||
Max int
|
||||
Tone BadgeTone
|
||||
Position BadgePosition
|
||||
// Label says what the badge counts; it is read before the full value.
|
||||
Label string
|
||||
}
|
||||
|
||||
// Badge shows a short text or count. Inline badges take no content; corner
|
||||
// positions pin the badge on content's corner.
|
||||
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 := 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(18).Height(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(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,114 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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{})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# 011 Text 文本
|
||||
|
||||
## 用途
|
||||
|
||||
正文文本:自动换行、按行数省略、可选择复制。`EditableText` 是 Text 的可编辑模式,就地修改一段短文本(如文件名),并展示调用方的保存进度与保存结果。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
name := "Coronation.md"
|
||||
saveErr := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
|
||||
if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() {
|
||||
saveErr = "" // save name here; set saveErr on failure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Text(c *ui.Context, s string, opts TextOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxLines` | `int` | `0` | 超过该行数时以省略号截断;`0` 不截断;`1` 为单行。负数 panic。 |
|
||||
| `Selectable` | `bool` | `false` | 允许用指针或键盘选择并复制文本。 |
|
||||
|
||||
`func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Saving` | `bool` | `false` | 调用方正在保存:显示加载标记,并禁止再次编辑。 |
|
||||
| `Error` | `string` | `""` | 调用方保存失败的说明,显示在文本下方并报告给辅助技术。 |
|
||||
| `Label` | `string` | `""` | 辅助技术读出的名称。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 默认:可编辑文本带虚线下边,提示可以编辑。
|
||||
- 悬停:背景变为 `SurfaceHover`,尺寸不变。
|
||||
- 聚焦:2 DIP 焦点环。
|
||||
- 编辑中:显示 MyGo 文本输入框,酒红边框,选中扩展名之前的部分。
|
||||
- 保存中(`Saving`):显示加载标记,阻止进入编辑。
|
||||
- 错误(`Error`):虚线变为 `Danger`,下方显示错误说明,保留已确认的值。
|
||||
|
||||
## 事件
|
||||
|
||||
- `EditableText(...).Changed()`:用户确认了一个新值,`*value` 已更新。调用方在此保存,并通过 `Saving` 与 `Error` 回传结果。
|
||||
- 取消编辑不产生事件,`*value` 不变。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
| --- | --- |
|
||||
| Tab | 聚焦可编辑文本 |
|
||||
| Enter / 双击 | 开始编辑 |
|
||||
| Enter | 确认 |
|
||||
| Escape | 取消并恢复原值 |
|
||||
| 焦点移走 | 视为确认 |
|
||||
| Cmd/Ctrl+A、Cmd/Ctrl+C | 在 `Selectable` 文本上全选、复制 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 编辑模式基于 `ui.EditableText`。MyGo 不报告“取消”事件,组件只能保证取消后值不变,不能通知调用方。
|
||||
- 编辑模式只支持单行文本。
|
||||
- 值为空字符串时文本区域没有可见文字;组件保留最小宽度和虚线,保证仍可点击。
|
||||
- MyGo 无法检测文本是否真的被截断,所以截断时不会自动显示完整内容的提示。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 012 Heading 标题
|
||||
|
||||
## 用途
|
||||
|
||||
页面与区块的三级衬线标题,可带副标题与旧金色装饰分隔线(对称细线加中央菱形)。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Level` | `int` | `0`(即 1) | 1、2、3 级,对应 28、22、18 DIP。其他值 panic。 |
|
||||
| `Subtitle` | `string` | `""` | 标题下方的说明文字,使用 `TextMuted`。 |
|
||||
| `Divider` | `bool` | `false` | 在下方绘制装饰分隔线,颜色为 `Ornament`。 |
|
||||
|
||||
## 状态
|
||||
|
||||
纯展示,仅默认状态。字号按系统文本缩放(`Preferences().TextScale`)放大。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。装饰线不进入无障碍树。
|
||||
|
||||
## 限制
|
||||
|
||||
- 标题字体为 `theme.SerifFont`(Georgia、宋体类回退);系统缺字时由系统回退字体显示。
|
||||
- MyGo v0.2.10 没有 Heading 角色(`ui/access.go` 的 Role 列表中没有),辅助技术把标题读作普通文本,无法按标题层级导航。
|
||||
- 分隔线只在 Heading 内绘制,不依赖 Divider 组件。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 013 Icon 图标
|
||||
|
||||
## 用途
|
||||
|
||||
按名称显示 `icons` 包内置的 Lucide SVG 图标,可设尺寸与语义颜色。名称不存在时显示可见的回退标记并写警告日志。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "star", mujica.IconOptions{})
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Icon(c *ui.Context, name string, opts IconOptions) *ui.Element`
|
||||
|
||||
`name` 必填,空字符串 panic。可用名称见 `icons.Names()`。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Size` | `float32` | `0`(即 16) | 宽高(DIP),建议 16、20、24。负数 panic。 |
|
||||
| `Tone` | `IconTone` | `IconInherit` | `IconInherit` 沿用文字颜色;另有 `IconMuted`、`IconAccent`、`IconSuccess`、`IconWarning`、`IconDanger`、`IconInfo`。未知值 panic。 |
|
||||
| `Label` | `string` | `""` | 有含义的图标的无障碍名称;为空时图标视为装饰,不进入无障碍树。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 默认:按语义颜色显示图标。
|
||||
- 加载失败(名称不存在):在同尺寸方框中用 `Danger` 色绘制虚线框与斜线,提示文字为“缺少图标:名称”,无障碍名称相同。每个元素对每个缺失名称记录一次 `slog.Warn("mujica: icon missing", "name", ...)`。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只能使用 `icons` 包内置的图标。要显示任意 SVG,可直接用 `ui.Icon`。
|
||||
- 回退标记带悬停提示,但图标本身不获取焦点,键盘用户看不到这个提示,只能依赖无障碍名称。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 014 Image 图片
|
||||
|
||||
## 用途
|
||||
|
||||
显示位图(`*ui.Bitmap`)或 SVG(`*ui.SVG`),可选包含或裁切填充;提供占位、加载中、加载失败三种状态和替代说明。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var photo *ui.Bitmap // decode once with ui.DecodeBitmap, outside the view
|
||||
loading, loadErr := false, errors.New("timeout")
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
img := mujica.Image(c, photo, mujica.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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Image(c *ui.Context, src ui.ImageSource, opts ImageOptions) ImageView`
|
||||
|
||||
`src` 为 `nil`(未赋值的接口)时显示占位。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Width`, `Height` | `float32` | `0` | 盒子尺寸(DIP)。为 0 时图片使用自身尺寸;占位、加载、错误状态为 160×120。负数 panic。 |
|
||||
| `Crop` | `bool` | `false` | `true` 覆盖并裁切(`ui.Cover`);`false` 完整放入(`ui.Contain`)。 |
|
||||
| `Alt` | `string` | `""` | 替代说明,供辅助技术读取;在其他状态下作为描述。 |
|
||||
| `Loading` | `bool` | `false` | 调用方正在加载。 |
|
||||
| `Err` | `error` | `nil` | 调用方加载失败;优先于 `Loading`。 |
|
||||
|
||||
返回值 `ImageView`:`Element` 为外层元素;`Retry` 表示本帧点击了重试。
|
||||
|
||||
## 状态
|
||||
|
||||
优先级:错误 > 加载中 > 占位 > 图片。
|
||||
|
||||
- 占位:浅色框内显示图片图标,名称“暂无图片”。
|
||||
- 加载中:框内显示 Spinner,名称“正在加载图片”。
|
||||
- 错误:显示错误图标、“图片加载失败”和“重试”按钮,`Err.Error()` 作为无障碍错误说明。
|
||||
|
||||
## 事件
|
||||
|
||||
- `ImageView.Retry`:用户点击了重试,或在按钮上按 Enter/Space。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
只有错误状态的“重试”按钮可以聚焦(Tab),用 Enter 或 Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不读取文件,也不访问网络。解码(`ui.DecodeBitmap`、`ui.ParseSVG`)和异步加载由调用方完成,结果通过 `Window.Update` 回到界面线程,再设置 `Loading` 与 `Err`。
|
||||
- SVG 的绘制范围以 MyGo 支持的部分为准:不支持文本、嵌入图片、pattern、marker 和 filter。
|
||||
- 接口里装着 nil 指针(如 `(*ui.Bitmap)(nil)`)时不算 `nil`,不会显示占位。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 015 Avatar 头像
|
||||
|
||||
## 用途
|
||||
|
||||
圆形人物头像。依次回退:图片 → 姓名首字母 → 默认人物图标。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Avatar(c *ui.Context, name string, opts AvatarOptions) *ui.Element`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Image` | `*ui.Bitmap` | `nil` | 头像图片,按圆形裁切填充。 |
|
||||
| `Size` | `float32` | `0`(即 32) | 直径(DIP)。负数 panic。 |
|
||||
|
||||
`name` 用作无障碍名称和首字母来源;为空时无障碍名称是“未命名”。
|
||||
|
||||
## 状态
|
||||
|
||||
- 图片:圆形裁切。
|
||||
- 首字母:取第一个和最后一个词的首字母(大写),衬线字体,`AccentText` 文字配 `Selection` 底色。
|
||||
- 无可用首字母:`SurfaceHover` 底色加人物图标。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。辅助技术把头像读作以 `name` 命名的图片。
|
||||
|
||||
## 限制
|
||||
|
||||
- 中文姓名没有空格时按一个词处理,只取第一个字,例如“张伟”显示为“张”。
|
||||
- 头像不使用 MyGo `ui.Avatar`,因为它的尺寸固定为字号的 2.25 倍,无法配置(`ui/indicators.go:383`)。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 016 AvatarGroup 头像组
|
||||
|
||||
## 用途
|
||||
|
||||
把多个头像重叠排列。超过最大数量时显示“+N”按钮,点开后在弹层中列出完整名单。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
people := []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
|
||||
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func AvatarGroup(c *ui.Context, items []AvatarItem, opts AvatarGroupOptions) AvatarGroupView`
|
||||
|
||||
`AvatarItem`:`Name string`、`Image *ui.Bitmap`。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Max` | `int` | `0`(即 4) | 最多直接显示的头像数。负数 panic。 |
|
||||
| `Size` | `float32` | `0`(即 32) | 每个头像的直径。负数 panic。 |
|
||||
|
||||
返回值 `AvatarGroupView`:`Element`;`Open` 表示完整名单正在显示;`Toggled` 表示本帧打开或关闭了名单。
|
||||
|
||||
## 状态
|
||||
|
||||
- 默认:头像之间重叠四分之一直径,用 2 DIP 窗口背景色描边分隔。
|
||||
- “+N”按钮:悬停、按下改变背景;聚焦时显示 2 DIP 焦点环。
|
||||
- 展开:按钮下方弹出 6 DIP 圆角面板,列出全部头像与姓名,超过 240 DIP 时滚动;遵循减少动画设置。
|
||||
|
||||
## 事件
|
||||
|
||||
- `AvatarGroupView.Toggled` / `Open`:名单展开或关闭(点击按钮、Enter、Space、Escape、点击外部)。同时记录 `slog.Debug("mujica: avatargroup toggle")`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
| --- | --- |
|
||||
| Tab | 聚焦“+N”按钮(读作“显示全部 N 人”) |
|
||||
| Enter / Space | 展开或收起完整名单 |
|
||||
| Escape | 关闭名单 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 分隔描边使用窗口 `Background` 颜色,放在 `Surface` 卡片上时描边颜色会与卡片背景不同。
|
||||
- 名单中的条目只用于展示,不可选择。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 017 Badge 徽标
|
||||
|
||||
## 用途
|
||||
|
||||
显示短文字或计数。计数超过上限时显示为“上限+”。徽标可以内联排列,也可以钉在内容的某个角上。辅助技术朗读完整数值。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
unread := 120
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Badge(c *ui.Context, opts BadgeOptions, content func()) *ui.Element`
|
||||
|
||||
`Position` 为 `BadgeInline` 时 `content` 必须为 `nil`;为角落位置时 `content` 必须不为 `nil`。否则 panic。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Text` | `string` | `""` | 显示的文字;非空时忽略 `Count`。 |
|
||||
| `Count` | `int` | `0` | 计数。`Text` 为空且计数为 0 时不显示徽标。负数 panic。 |
|
||||
| `Max` | `int` | `0`(即 99) | 显示上限,超过时显示 `Max+`。负数 panic。 |
|
||||
| `Tone` | `BadgeTone` | `BadgeAccent` | `BadgeAccent`、`BadgeNeutral`、`BadgeSuccess`、`BadgeWarning`、`BadgeDanger`、`BadgeInfo`。 |
|
||||
| `Position` | `BadgePosition` | `BadgeInline` | `BadgeTopRight`、`BadgeTopLeft`、`BadgeBottomRight`、`BadgeBottomLeft`:徽标中心对准内容的对应角。 |
|
||||
| `Label` | `string` | `""` | 计数的含义,朗读时放在完整数值前,如“Unread 1204”。 |
|
||||
|
||||
## 状态
|
||||
|
||||
纯展示。强调和危险色调使用实色填充(`OnAccent` 配 `Accent`,`OnDanger` 配 `DangerFill`);成功、警告、信息色调只把语义色用在文字和边框上。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点。徽标的无障碍名称为 `Label` 加完整数值。
|
||||
|
||||
## 限制
|
||||
|
||||
- 角落徽标的位置相对于 `content` 所在的盒子。内容本身有外边距时,徽标不会跟着偏移。
|
||||
- 徽标使用 `RoleStatus`,部分屏幕阅读器可能在数值变化时主动播报。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 018 Tag 标签
|
||||
|
||||
## 用途
|
||||
|
||||
短标签,可带图标。可以点击,也可以关闭,点击标签和关闭标签是两个独立的操作,各有自己的焦点停靠点。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
tags := []string{"Baroque", "Gothic"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for i, t := range tags {
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
v.Element.Key(t)
|
||||
if v.Closed {
|
||||
tags = append(tags[:i:i], tags[i+1:]...)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Tag(c *ui.Context, label string, opts TagOptions) TagView`
|
||||
|
||||
`label` 必填,空字符串 panic。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Icon` | `string` | `""` | 图标名称,见 Icon 组件。 |
|
||||
| `Clickable` | `bool` | `false` | 标签文字部分作为按钮,可以聚焦和点击。 |
|
||||
| `Closable` | `bool` | `false` | 显示 28×28 DIP 的关闭按钮。 |
|
||||
| `Disabled` | `bool` | `false` | 禁用点击和关闭,并移出 Tab 顺序。 |
|
||||
|
||||
返回值 `TagView`:`Element`;`Clicked` 表示标签被激活;`Closed` 表示请求关闭。
|
||||
|
||||
## 状态
|
||||
|
||||
- 默认:`Surface` 底色、细边框、2 DIP 圆角。
|
||||
- 悬停 / 按下:可点击部分和关闭按钮分别变为 `SurfaceHover` / `Selection`。
|
||||
- 聚焦:对应部分显示 2 DIP 焦点环。
|
||||
- 禁用:由 MyGo 统一调暗,不响应输入。
|
||||
|
||||
## 事件
|
||||
|
||||
- `TagView.Clicked`:点击标签文字,或在聚焦时按 Enter/Space。点击关闭按钮不会触发它。
|
||||
- `TagView.Closed`:点击关闭按钮,或在关闭按钮上按 Enter/Space,或在聚焦的可点击标签上按 Delete/Backspace。组件不会自行移除标签,由调用方删除数据。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
| --- | --- |
|
||||
| Tab | 先聚焦标签文字(仅 `Clickable`),再聚焦关闭按钮 |
|
||||
| Enter / Space | 激活当前聚焦的部分 |
|
||||
| Delete / Backspace | 在可点击标签上请求关闭(仅 `Closable`) |
|
||||
|
||||
## 限制
|
||||
|
||||
- 不可点击的标签不获取焦点,只能通过关闭按钮操作。
|
||||
- 标签不提供“选中”状态;需要可选标签时请使用 Toggle 类组件。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 019 Link 链接
|
||||
|
||||
## 用途
|
||||
|
||||
带下划线的链接文本:打开外部地址、在 MyGo Router 中跳转路径,或作为应用内动作。可以显示打开失败的反馈。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
details := false
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element`
|
||||
|
||||
`label` 必填,空字符串 panic。
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `URL` | `string` | `""` | 有协议的地址用系统默认应用打开。没有协议的路径(如 `/notes/42`、`?tab=info`)在 MyGo Router 中跳转。为空时是应用内动作。 |
|
||||
| `Error` | `string` | `""` | 调用方提供的打开失败说明,显示在链接下方,也报告给辅助技术。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 默认:`AccentText` 颜色,始终带 1 DIP 下划线。外部地址后面带“外部链接”小图标。
|
||||
- 悬停 / 按下:文字与下划线变为 `Text` 颜色,布局不变。
|
||||
- 聚焦:2 DIP 焦点环。
|
||||
- 错误:格式不正确的地址(无法解析,或 http/https 缺少主机名)不会打开,下方显示“无法打开此链接:地址”;`Error` 非空时优先显示 `Error`。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Clicked()`:每次点击或按 Enter 都会报告,无论是否打开了地址。同时记录 `slog.Debug("mujica: link activate")`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
| --- | --- |
|
||||
| Tab | 聚焦链接 |
|
||||
| Enter | 激活 |
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo v0.2.10 不报告打开失败。`ui.Link` 和 `Context.OpenURL` 不返回结果(`ui/context.go:270`、`ui/window.go:583`)。窗口宿主在 goroutine 中调用 `Shell.OpenExternal` 并丢弃它的错误(`content.go:55`)。因此组件只能自行检查地址格式,系统打开失败无法自动显示。需要这种反馈时,传空 `URL`,在 `Clicked()` 后由调用方在 goroutine 中调用 `mygo.Shell.OpenExternal(url)`。它会返回 error,把结果通过 `Window.Update` 写回 `Error`。
|
||||
- 路径是否交给 Router 由 MyGo 决定。没有 Router 时,路径会被当作 URL 交给系统打开。
|
||||
- 链接不能嵌入 `ui.RichText` 句子中作为行内元素,因为外部图标和错误说明需要额外的行布局。行内链接请直接使用 `ui.Link`。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 020 Kbd 快捷键
|
||||
|
||||
## 用途
|
||||
|
||||
把单键或组合键显示为键帽。按平台显示:macOS 用 ⌘ ⇧ ⌥ ⌃ 等符号且不加连接符;其他平台显示 Ctrl、Shift、Alt,键之间用“+”连接。只做展示,不注册快捷键。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(6).Children(func() {
|
||||
mujica.Text(c, "Command palette", mujica.TextOptions{})
|
||||
mujica.Kbd(c, "Mod", "Shift", "P")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`func Kbd(c *ui.Context, keys ...string) *ui.Element`
|
||||
|
||||
至少要传一个键,键名不能为空,否则 panic。键名不区分大小写:
|
||||
|
||||
| 键名 | macOS | 其他平台 |
|
||||
| --- | --- | --- |
|
||||
| `Mod` | ⌘(Command) | Ctrl |
|
||||
| `Ctrl` | ⌃ | Ctrl |
|
||||
| `Alt` | ⌥(Option) | Alt |
|
||||
| `Shift` | ⇧ | Shift |
|
||||
| `Enter` | ↩ | Enter |
|
||||
| `Esc` | esc | Esc |
|
||||
| `Tab` | ⇥ | Tab |
|
||||
| `Backspace` | ⌫ | Backspace |
|
||||
| `Delete` | ⌦ | Delete |
|
||||
| `Up` `Down` `Left` `Right` | ↑ ↓ ← → | ↑ ↓ ← → |
|
||||
|
||||
其他键名按原样显示;单个字符转为大写。
|
||||
|
||||
## 状态
|
||||
|
||||
纯展示。键帽使用等宽字体、`Surface` 底色、`ControlBorder` 边框,底边 2 DIP。
|
||||
|
||||
## 事件
|
||||
|
||||
无。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
不获取焦点,也不注册任何快捷键。辅助技术读出完整的组合键文字,例如“Command+Shift+P”或“Ctrl+Shift+P”。
|
||||
|
||||
## 限制
|
||||
|
||||
- 平台在编译时由 `runtime.GOOS` 决定。Gallery 的网页镜像显示的是运行 Go 进程那台机器的平台。
|
||||
- `Mod` 与 MyGo 的 `ui.Cmd` 一致:macOS 上是 Command,其他平台是 Control。
|
||||
@@ -0,0 +1,134 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleText() {
|
||||
name := "Coronation.md"
|
||||
saveErr := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
|
||||
if mujica.EditableText(c, &name, mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleIcon() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "star", mujica.IconOptions{})
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
img := mujica.Image(c, photo, mujica.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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAvatarGroup() {
|
||||
people := []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"},
|
||||
{Name: "Umiri Yahata"}, {Name: "Nyamu Yutenji"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBadge() {
|
||||
unread := 120
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTag() {
|
||||
tags := []string{"Baroque", "Gothic"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
for i, t := range tags {
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
v.Element.Key(t)
|
||||
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) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleKbd() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Row(c).Gap(6).Children(func() {
|
||||
mujica.Text(c, "Command palette", mujica.TextOptions{})
|
||||
mujica.Kbd(c, "Mod", "Shift", "P")
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,496 @@
|
||||
package main
|
||||
|
||||
var displayPages = []page{}
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
var displayPages = []page{
|
||||
{ID: "011", Name: "Text", ZhName: "文本", Category: "Display", Demo: textDemo, Code: textCode,
|
||||
Keys: "Selectable 文本:点击后 Cmd/Ctrl+A 全选、Cmd/Ctrl+C 复制。可编辑:Tab 聚焦,Enter 或双击开始编辑,Enter 确认,Escape 取消,移走焦点也确认。"},
|
||||
{ID: "012", Name: "Heading", ZhName: "标题", Category: "Display", Demo: headingDemo, Code: headingCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "013", Name: "Icon", ZhName: "图标", Category: "Display", Demo: iconDemo, Code: iconCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "014", Name: "Image", ZhName: "图片", Category: "Display", Demo: imageDemo, Code: imageCode,
|
||||
Keys: "错误状态的「重试」按钮可 Tab 聚焦,Enter 或 Space 触发。"},
|
||||
{ID: "015", Name: "Avatar", ZhName: "头像", Category: "Display", Demo: avatarDemo, Code: avatarCode,
|
||||
Keys: "纯展示,不获取焦点。"},
|
||||
{ID: "016", Name: "AvatarGroup", ZhName: "头像组", Category: "Display", Demo: avatarGroupDemo, Code: avatarGroupCode,
|
||||
Keys: "Tab 聚焦「+N」按钮,Enter 或 Space 展开完整名单,Escape 或点击外部关闭。"},
|
||||
{ID: "017", Name: "Badge", ZhName: "徽标", Category: "Display", Demo: badgeDemo, Code: badgeCode,
|
||||
Keys: "纯展示,不获取焦点;辅助技术朗读完整数值。"},
|
||||
{ID: "018", Name: "Tag", ZhName: "标签", Category: "Display", Demo: tagDemo, Code: tagCode,
|
||||
Keys: "可点击标签:Tab 聚焦,Enter 或 Space 点击,Delete 或 Backspace 关闭。关闭按钮是独立的 Tab 停靠点。"},
|
||||
{ID: "019", Name: "Link", ZhName: "链接", Category: "Display", Demo: linkDemo, Code: linkCode,
|
||||
Keys: "Tab 聚焦链接,Enter 激活。"},
|
||||
{ID: "020", Name: "Kbd", ZhName: "快捷键", Category: "Display", Demo: kbdDemo, Code: kbdCode,
|
||||
Keys: "纯展示,不注册快捷键,不获取焦点。"},
|
||||
}
|
||||
|
||||
type textState struct {
|
||||
maxLines float64
|
||||
selectable bool
|
||||
name string
|
||||
saving bool
|
||||
failNext bool
|
||||
saveErr string
|
||||
}
|
||||
|
||||
func textDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "011", func() textState { return textState{maxLines: 0, selectable: true, name: "Coronation.md"} })
|
||||
mujica.Heading(c, "Text", mujica.HeadingOptions{Subtitle: "换行、省略、选择复制与可编辑模式", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("MaxLines %d", int(s.maxLines)))
|
||||
ui.StepSlider(c, &s.maxLines, 0, 4, 1).Width(160)
|
||||
ui.Checkbox(c, &s.selectable, "Selectable")
|
||||
ui.Checkbox(c, &s.saving, "Saving")
|
||||
ui.Checkbox(c, &s.failNext, "Next save fails")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Display", func() {
|
||||
ui.Box(c).Width(360).Children(func() {
|
||||
mujica.Text(c, "Velvet curtains fall over the long gallery. Every candle leans toward the open door, and the quartet plays on while the guests take their places beneath the chandeliers.",
|
||||
mujica.TextOptions{MaxLines: int(s.maxLines), Selectable: s.selectable})
|
||||
})
|
||||
})
|
||||
displaySection(c, "Editable", func() {
|
||||
e := mujica.EditableText(c, &s.name, mujica.EditableTextOptions{Saving: s.saving, Error: s.saveErr, Label: "File name"})
|
||||
if e.Changed() {
|
||||
if s.failNext {
|
||||
s.saveErr = "Save failed: the name is already taken."
|
||||
log("save failed: " + s.name)
|
||||
} else {
|
||||
s.saveErr = ""
|
||||
log("saved: " + s.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
displayCode(c, textCode, log)
|
||||
}
|
||||
|
||||
const textCode = `mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
mujica.Text(c, "A long note that stops after two lines.", mujica.TextOptions{MaxLines: 2})
|
||||
if mujica.EditableText(c, &name, mujica.EditableTextOptions{Error: saveErr}).Changed() {
|
||||
saveErr = "" // save name here; set saveErr on failure
|
||||
}`
|
||||
|
||||
type headingState struct {
|
||||
level float64
|
||||
subtitle, divide bool
|
||||
scale float64
|
||||
}
|
||||
|
||||
func headingDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "012", func() headingState { return headingState{level: 1, subtitle: true, divide: true} })
|
||||
mujica.Heading(c, "Heading", mujica.HeadingOptions{Subtitle: "三级衬线标题、副标题与装饰分隔线", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Level %d", int(s.level)))
|
||||
if ui.StepSlider(c, &s.level, 1, 3, 1).Width(120).Changed() {
|
||||
log(fmt.Sprintf("level %d", int(s.level)))
|
||||
}
|
||||
ui.Checkbox(c, &s.subtitle, "Subtitle")
|
||||
ui.Checkbox(c, &s.divide, "Divider")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
sub := ""
|
||||
if s.subtitle {
|
||||
sub = "Ceremonies of the winter season"
|
||||
}
|
||||
mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Level: int(s.level), Subtitle: sub, Divider: s.divide})
|
||||
})
|
||||
displaySection(c, "Levels", func() {
|
||||
for l := 1; l <= 3; l++ {
|
||||
mujica.Heading(c, fmt.Sprintf("Level %d · 宫廷", l), mujica.HeadingOptions{Level: l})
|
||||
}
|
||||
})
|
||||
displayCode(c, headingCode, log)
|
||||
}
|
||||
|
||||
const headingCode = `mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
mujica.Heading(c, "Guest Ledger", mujica.HeadingOptions{Level: 2})`
|
||||
|
||||
type iconState struct {
|
||||
name string
|
||||
size string
|
||||
tone string
|
||||
label bool
|
||||
}
|
||||
|
||||
var iconTones = map[string]mujica.IconTone{"Inherit": mujica.IconInherit, "Muted": mujica.IconMuted, "Accent": mujica.IconAccent,
|
||||
"Success": mujica.IconSuccess, "Warning": mujica.IconWarning, "Danger": mujica.IconDanger, "Info": mujica.IconInfo}
|
||||
|
||||
func iconDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "013", func() iconState { return iconState{name: "star", size: "24", tone: "Accent"} })
|
||||
mujica.Heading(c, "Icon", mujica.HeadingOptions{Subtitle: "按名称取图标;尺寸与语义颜色;缺失时可见回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.Select(c, &s.name, []string{"star", "bell", "settings", "moon", "sun", "no-such-icon"}).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
if ui.Select(c, &s.size, []string{"16", "20", "24"}).Changed() {
|
||||
log("size " + s.size)
|
||||
}
|
||||
if ui.Select(c, &s.tone, []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"}).Changed() {
|
||||
log("tone " + s.tone)
|
||||
}
|
||||
ui.Checkbox(c, &s.label, "Label")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var size float32
|
||||
fmt.Sscan(s.size, &size)
|
||||
label := ""
|
||||
if s.label {
|
||||
label = "Preview icon"
|
||||
}
|
||||
mujica.Icon(c, s.name, mujica.IconOptions{Size: size, Tone: iconTones[s.tone], Label: label})
|
||||
})
|
||||
displaySection(c, "Tones and fallback", func() {
|
||||
ui.Row(c).Gap(14).Children(func() {
|
||||
for _, t := range []string{"Inherit", "Muted", "Accent", "Success", "Warning", "Danger", "Info"} {
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: iconTones[t]}).Key(t)
|
||||
}
|
||||
mujica.Icon(c, "missing-name", mujica.IconOptions{Size: 20})
|
||||
})
|
||||
})
|
||||
displayCode(c, iconCode, log)
|
||||
}
|
||||
|
||||
const iconCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Icon(c, "star", mujica.IconOptions{})
|
||||
mujica.Icon(c, "circle-check", mujica.IconOptions{Size: 20, Tone: mujica.IconSuccess, Label: "Done"})
|
||||
})`
|
||||
|
||||
type imageState struct {
|
||||
crop, loading, fail, empty bool
|
||||
err error
|
||||
}
|
||||
|
||||
var displayPicture = func() *ui.SVG {
|
||||
return ui.MustParseSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
|
||||
<rect width="240" height="160" fill="#2B2126"/><rect x="20" y="20" width="200" height="120" fill="none" stroke="#BDA172" stroke-width="2"/>
|
||||
<path d="M120 40 L150 80 L120 120 L90 80 Z" fill="#7D2034"/><circle cx="120" cy="80" r="8" fill="#F2EBDD"/></svg>`))
|
||||
}()
|
||||
|
||||
func imageDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "014", func() imageState { return imageState{crop: true} })
|
||||
mujica.Heading(c, "Image", mujica.HeadingOptions{Subtitle: "位图与 SVG;包含或裁切;占位、加载、错误;替代说明", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Checkbox(c, &s.crop, "Crop")
|
||||
ui.Checkbox(c, &s.empty, "No source")
|
||||
ui.Checkbox(c, &s.loading, "Loading")
|
||||
if ui.Checkbox(c, &s.fail, "Error").Changed() {
|
||||
s.err = nil
|
||||
if s.fail {
|
||||
s.err = errors.New("connection reset")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var src ui.ImageSource = displayPicture
|
||||
if s.empty {
|
||||
src = nil
|
||||
}
|
||||
v := mujica.Image(c, src, mujica.ImageOptions{Width: 200, Height: 200, Crop: s.crop, Alt: "Gothic emblem", Loading: s.loading, Err: s.err})
|
||||
if v.Retry {
|
||||
s.fail, s.err = false, nil
|
||||
log("retry")
|
||||
}
|
||||
})
|
||||
displaySection(c, "States", func() {
|
||||
ui.Row(c).Gap(12).Wrap().Children(func() {
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, displayPicture, mujica.ImageOptions{Width: 120, Height: 120, Crop: true})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120})
|
||||
mujica.Image(c, nil, mujica.ImageOptions{Width: 120, Height: 120, Loading: true})
|
||||
if mujica.Image(c, nil, mujica.ImageOptions{Width: 160, Height: 140, Err: errors.New("404")}).Retry {
|
||||
log("retry (static example)")
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, imageCode, log)
|
||||
}
|
||||
|
||||
const imageCode = `img := mujica.Image(c, photo, mujica.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
|
||||
}`
|
||||
|
||||
type avatarState struct {
|
||||
name string
|
||||
size float64
|
||||
img bool
|
||||
}
|
||||
|
||||
var displayBitmap = func() *ui.Bitmap {
|
||||
return ui.NewBitmap(ui.Render(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mujica.Dark})
|
||||
ui.Box(c).Fill().Background(ui.Hex("#7D2034"))
|
||||
}, 64, 64, 1))
|
||||
}()
|
||||
|
||||
func avatarDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "015", func() avatarState { return avatarState{name: "Sakiko Togawa", size: 48} })
|
||||
mujica.Heading(c, "Avatar", mujica.HeadingOptions{Subtitle: "图片、姓名首字母与默认图标回退", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.name).Width(200).Changed() {
|
||||
log("name " + s.name)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("Size %d", int(s.size)))
|
||||
ui.StepSlider(c, &s.size, 24, 80, 4).Width(160)
|
||||
ui.Checkbox(c, &s.img, "Image")
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var img *ui.Bitmap
|
||||
if s.img {
|
||||
img = displayBitmap
|
||||
}
|
||||
mujica.Avatar(c, s.name, mujica.AvatarOptions{Size: float32(s.size), Image: img})
|
||||
})
|
||||
displaySection(c, "Fallbacks", func() {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Avatar(c, "Photo", mujica.AvatarOptions{Image: displayBitmap, Size: 40})
|
||||
mujica.Avatar(c, "Uika Misumi", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "若叶睦", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
|
||||
})
|
||||
})
|
||||
displayCode(c, avatarCode, log)
|
||||
}
|
||||
|
||||
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40})
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{})
|
||||
})`
|
||||
|
||||
var displayPeople = []mujica.AvatarItem{
|
||||
{Name: "Sakiko Togawa"}, {Name: "Uika Misumi"}, {Name: "Mutsumi Wakaba"}, {Name: "Umiri Yahata"},
|
||||
{Name: "Nyamu Yutenji"}, {Name: "Tomori Takamatsu"}, {Name: "Anon Chihaya"}, {Name: "Rana Kaname"},
|
||||
}
|
||||
|
||||
type avatarGroupState struct{ max, count float64 }
|
||||
|
||||
func avatarGroupDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "016", func() avatarGroupState { return avatarGroupState{max: 3, count: 6} })
|
||||
mujica.Heading(c, "AvatarGroup", mujica.HeadingOptions{Subtitle: "最大显示数量、剩余数量与完整名单", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Max %d", int(s.max)))
|
||||
if ui.StepSlider(c, &s.max, 1, 8, 1).Width(160).Changed() {
|
||||
log(fmt.Sprintf("max %d", int(s.max)))
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("People %d", int(s.count)))
|
||||
if ui.StepSlider(c, &s.count, 1, 8, 1).Width(160).Changed() {
|
||||
log(fmt.Sprintf("people %d", int(s.count)))
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
for i, g := range []mujica.AvatarGroupView{
|
||||
mujica.AvatarGroup(c, displayPeople[:int(s.count)], mujica.AvatarGroupOptions{Max: int(s.max)}),
|
||||
mujica.AvatarGroup(c, displayPeople, mujica.AvatarGroupOptions{Max: 4, Size: 24}),
|
||||
} {
|
||||
if g.Toggled {
|
||||
log(fmt.Sprintf("group %d open=%v", i+1, g.Open))
|
||||
}
|
||||
}
|
||||
})
|
||||
displayCode(c, avatarGroupCode, log)
|
||||
}
|
||||
|
||||
const avatarGroupCode = `mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})`
|
||||
|
||||
type badgeState struct {
|
||||
count, max float64
|
||||
text string
|
||||
tone, pos string
|
||||
}
|
||||
|
||||
var badgeTones = map[string]mujica.BadgeTone{"Accent": mujica.BadgeAccent, "Neutral": mujica.BadgeNeutral, "Success": mujica.BadgeSuccess,
|
||||
"Warning": mujica.BadgeWarning, "Danger": mujica.BadgeDanger, "Info": mujica.BadgeInfo}
|
||||
var badgePositions = map[string]mujica.BadgePosition{"TopRight": mujica.BadgeTopRight, "TopLeft": mujica.BadgeTopLeft,
|
||||
"BottomRight": mujica.BadgeBottomRight, "BottomLeft": mujica.BadgeBottomLeft}
|
||||
|
||||
func badgeDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "017", func() badgeState { return badgeState{count: 120, max: 99, tone: "Accent", pos: "TopRight"} })
|
||||
mujica.Heading(c, "Badge", mujica.HeadingOptions{Subtitle: "文字或计数、上限、位置与完整朗读", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Count %d", int(s.count)))
|
||||
ui.StepSlider(c, &s.count, 0, 200, 1).Width(160)
|
||||
ui.Text(c, fmt.Sprintf("Max %d", int(s.max)))
|
||||
ui.StepSlider(c, &s.max, 9, 999, 10).Width(120)
|
||||
ui.TextInput(c, &s.text).Placeholder("Text overrides count").Width(180)
|
||||
if ui.Select(c, &s.tone, []string{"Accent", "Neutral", "Success", "Warning", "Danger", "Info"}).Changed() {
|
||||
log("tone " + s.tone)
|
||||
}
|
||||
if ui.Select(c, &s.pos, []string{"TopRight", "TopLeft", "BottomRight", "BottomLeft"}).Changed() {
|
||||
log("position " + s.pos)
|
||||
}
|
||||
})
|
||||
})
|
||||
opts := mujica.BadgeOptions{Text: s.text, Count: int(s.count), Max: int(s.max), Tone: badgeTones[s.tone], Label: "Unread"}
|
||||
displaySection(c, "Inline", func() {
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, "Inbox")
|
||||
mujica.Badge(c, opts, nil)
|
||||
})
|
||||
})
|
||||
displaySection(c, "Corner", func() {
|
||||
opts.Position = badgePositions[s.pos]
|
||||
ui.Box(c).Padding(10).Children(func() {
|
||||
mujica.Badge(c, opts, func() { mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) })
|
||||
})
|
||||
})
|
||||
displayCode(c, badgeCode, log)
|
||||
}
|
||||
|
||||
const badgeCode = `mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
mujica.Badge(c, mujica.BadgeOptions{Count: unread, Label: "Unread", Position: mujica.BadgeTopRight}, func() {
|
||||
mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{})
|
||||
})`
|
||||
|
||||
type tagState struct {
|
||||
tags []string
|
||||
icon, clickable, closable, dis bool
|
||||
}
|
||||
|
||||
func tagDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "018", func() tagState {
|
||||
return tagState{tags: []string{"Baroque", "Gothic", "Rococo", "Court"}, icon: true, clickable: true, closable: true}
|
||||
})
|
||||
mujica.Heading(c, "Tag", mujica.HeadingOptions{Subtitle: "标签文字、图标;点击与关闭分离", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.Checkbox(c, &s.icon, "Icon")
|
||||
ui.Checkbox(c, &s.clickable, "Clickable")
|
||||
ui.Checkbox(c, &s.closable, "Closable")
|
||||
ui.Checkbox(c, &s.dis, "Disabled")
|
||||
if mujica.Button(c, "Reset tags", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.tags = []string{"Baroque", "Gothic", "Rococo", "Court"}
|
||||
log("reset")
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
icon := ""
|
||||
if s.icon {
|
||||
icon = "star"
|
||||
}
|
||||
ui.Row(c).Gap(8).Wrap().Children(func() {
|
||||
for i := 0; i < len(s.tags); i++ {
|
||||
t := s.tags[i]
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: icon, Clickable: s.clickable, Closable: s.closable, Disabled: s.dis})
|
||||
v.Element.Key(t)
|
||||
if v.Clicked {
|
||||
log("clicked " + t)
|
||||
}
|
||||
if v.Closed {
|
||||
log("closed " + t)
|
||||
s.tags = append(s.tags[:i:i], s.tags[i+1:]...)
|
||||
i--
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, tagCode, log)
|
||||
}
|
||||
|
||||
const tagCode = `for i, t := range tags {
|
||||
v := mujica.Tag(c, t, mujica.TagOptions{Icon: "star", Clickable: true, Closable: true})
|
||||
v.Element.Key(t)
|
||||
if v.Closed {
|
||||
tags = append(tags[:i:i], tags[i+1:]...)
|
||||
}
|
||||
}`
|
||||
|
||||
type linkState struct {
|
||||
url string
|
||||
external string
|
||||
}
|
||||
|
||||
func linkDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "019", func() linkState { return linkState{url: "https://example.com/charter"} })
|
||||
mujica.Heading(c, "Link", mujica.HeadingOptions{Subtitle: "外部地址或应用内动作;键盘激活;打开失败反馈", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
ui.TextInput(c, &s.url).Placeholder("URL (empty = in-app action)").Width(320)
|
||||
if mujica.Button(c, "Simulate open failure", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.external = "No application can open this link."
|
||||
log("open failure set")
|
||||
}
|
||||
if mujica.Button(c, "Clear error", mujica.ButtonOptions{Variant: mujica.Ghost}).Clicked() {
|
||||
s.external = ""
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
if mujica.Link(c, "Read the court charter", mujica.LinkOptions{URL: s.url, Error: s.external}).Clicked() {
|
||||
log("activated " + s.url)
|
||||
}
|
||||
if mujica.Link(c, "Show the guest list", mujica.LinkOptions{}).Clicked() {
|
||||
log("in-app action")
|
||||
}
|
||||
})
|
||||
displayCode(c, linkCode, log)
|
||||
}
|
||||
|
||||
const linkCode = `mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
if mujica.Link(c, "Show details", mujica.LinkOptions{}).Clicked() {
|
||||
details = !details
|
||||
}`
|
||||
|
||||
type kbdState struct{ combo string }
|
||||
|
||||
func kbdDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "020", func() kbdState { return kbdState{combo: "Mod+Shift+P"} })
|
||||
mujica.Heading(c, "Kbd", mujica.HeadingOptions{Subtitle: "单键与组合键;按平台显示 Command 或 Ctrl", Divider: true})
|
||||
displaySection(c, "Parameters", func() {
|
||||
displayControls(c, func() {
|
||||
if ui.TextInput(c, &s.combo).Placeholder("Keys joined by +").Width(220).Changed() {
|
||||
log("keys " + s.combo)
|
||||
}
|
||||
})
|
||||
})
|
||||
displaySection(c, "Preview", func() {
|
||||
var keys []string
|
||||
for _, k := range strings.Split(s.combo, "+") {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
mujica.Kbd(c, keys...)
|
||||
}
|
||||
})
|
||||
displaySection(c, "Common", func() {
|
||||
for _, row := range [][]string{{"Mod", "K"}, {"Mod", "Shift", "P"}, {"Alt", "Up"}, {"Esc"}, {"Ctrl", "Tab"}} {
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
mujica.Kbd(c, row...)
|
||||
ui.Text(c, strings.Join(row, " + ")).TextColor(mujica.Tokens(c).TextMuted)
|
||||
})
|
||||
}
|
||||
})
|
||||
displayCode(c, kbdCode, log)
|
||||
}
|
||||
|
||||
const kbdCode = `ui.Row(c).Gap(6).Children(func() {
|
||||
mujica.Text(c, "Command palette", mujica.TextOptions{})
|
||||
mujica.Kbd(c, "Mod", "Shift", "P")
|
||||
})`
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// displaySection shows a titled block of a demo.
|
||||
func displaySection(c *ui.Context, title string, fn func()) {
|
||||
ui.Column(c).Gap(10).Margin(0, 0, 20, 0).Children(func() {
|
||||
mujica.Heading(c, title, mujica.HeadingOptions{Level: 3})
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
// displayControls lays out a row of parameter controls.
|
||||
func displayControls(c *ui.Context, fn func()) {
|
||||
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn)
|
||||
}
|
||||
|
||||
// displayCode shows a page's snippet with a copy button.
|
||||
func displayCode(c *ui.Context, code string, log func(string)) {
|
||||
displaySection(c, "Code", func() {
|
||||
if mujica.Button(c, "Copy code", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() {
|
||||
c.WriteClipboard(code)
|
||||
log("code copied")
|
||||
}
|
||||
mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// HeadingOptions configure a Heading. The zero value is a level 1 heading.
|
||||
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 that follows the desktop's text scale.
|
||||
func Heading(c *ui.Context, title string, opts HeadingOptions) *ui.Element {
|
||||
k := Tokens(c)
|
||||
size := headingSize(opts.Level)
|
||||
scale := c.Preferences().TextScale
|
||||
if scale <= 0 {
|
||||
scale = 1
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
col := ui.Column(c).Gap(u).Shrink(0)
|
||||
col.Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(size * scale).FontWeight(600).
|
||||
LineHeight(1.25).TextColor(k.Text)
|
||||
if opts.Subtitle != "" {
|
||||
ui.Text(c, opts.Subtitle).FontSize(theme.BodySize * scale).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) {
|
||||
headingOrnament(p, r, 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")
|
||||
}
|
||||
|
||||
// headingOrnament draws symmetric rules around a center diamond.
|
||||
func headingOrnament(p *ui.Painter, r ui.Rect, c ui.Color) {
|
||||
cx, cy := r.X+r.W/2, r.Y+r.H/2
|
||||
const d, gap = 7, 6
|
||||
internal.Rule(p, r.X, cx-gap, cy, c)
|
||||
internal.Rule(p, cx+gap, r.X+r.W, cy, c)
|
||||
p.FillPath(internal.Diamond(cx, cy, d), c)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func headingHeights(t *testing.T, p ui.Preferences) (h1, h2, h3 float32) {
|
||||
t.Helper()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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,98 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// IconTone is an icon's semantic color. The zero value takes the text color.
|
||||
type IconTone int
|
||||
|
||||
const (
|
||||
IconInherit IconTone = iota
|
||||
IconMuted
|
||||
IconAccent
|
||||
IconSuccess
|
||||
IconWarning
|
||||
IconDanger
|
||||
IconInfo
|
||||
)
|
||||
|
||||
// IconOptions configure an Icon.
|
||||
type IconOptions struct {
|
||||
// Size is the icon's width and height in DIPs; zero means 16.
|
||||
Size float32
|
||||
Tone IconTone
|
||||
// Label names a meaningful icon for assistive technology; empty hides it.
|
||||
Label string
|
||||
}
|
||||
|
||||
type iconMissingKey struct{}
|
||||
|
||||
// Icon shows the named icon of the icons package. A missing name shows a
|
||||
// dashed fallback box in the Danger color and logs a warning.
|
||||
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 := 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(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,88 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestIconSizeAndLabel(t *testing.T) {
|
||||
var def, big ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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,86 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ImageOptions configure an Image.
|
||||
type ImageOptions struct {
|
||||
// Width and Height size the box in DIPs; zero keeps the picture's size,
|
||||
// or 160×120 for the placeholder, loading and error states.
|
||||
Width, Height float32
|
||||
// Crop covers the box and crops the rest; false fits the picture 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 what the user did to it this frame.
|
||||
type ImageView struct {
|
||||
Element *ui.Element
|
||||
// Retry reports a click on the retry button of the error state.
|
||||
Retry bool
|
||||
}
|
||||
|
||||
// Image shows a *ui.Bitmap or *ui.SVG. A nil src 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")
|
||||
}
|
||||
if opts.Err == nil && !opts.Loading && src != nil {
|
||||
e := ui.Image(c, src).Radius(theme.ControlRadius)
|
||||
if opts.Crop {
|
||||
e.Fit(ui.Cover)
|
||||
}
|
||||
if opts.Width > 0 {
|
||||
e.Width(opts.Width)
|
||||
}
|
||||
if opts.Height > 0 {
|
||||
e.Height(opts.Height)
|
||||
}
|
||||
if opts.Alt != "" {
|
||||
e.Label(opts.Alt)
|
||||
}
|
||||
return ImageView{Element: e}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
w, h := opts.Width, opts.Height
|
||||
if w == 0 {
|
||||
w = 160
|
||||
}
|
||||
if h == 0 {
|
||||
h = 120
|
||||
}
|
||||
var v ImageView
|
||||
frame := ui.Column(c).Size(w, h).Shrink(0).Center().Gap(u*2).Padding(u*2).Clip().
|
||||
Radius(theme.ControlRadius).Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border)
|
||||
v.Element = frame
|
||||
frame.Children(func() {
|
||||
switch {
|
||||
case opts.Err != nil:
|
||||
frame.Role(ui.RoleGroup).Label(msg(c, "image.error")).Description(opts.Alt).Error(opts.Err.Error())
|
||||
Icon(c, "circle-alert", IconOptions{Size: 20, Tone: IconDanger})
|
||||
ui.Text(c, msg(c, "image.error")).FontSize(theme.CaptionSize).TextColor(k.Danger).TextAlign(ui.Center)
|
||||
if Button(c, msg(c, "image.retry"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
v.Retry = true
|
||||
slog.Debug("mujica: image retry", "alt", opts.Alt)
|
||||
}
|
||||
case opts.Loading:
|
||||
frame.Role(ui.RoleImage).Label(msg(c, "image.loading")).Description(opts.Alt)
|
||||
Spinner(c, SpinnerOptions{Size: 20, Label: msg(c, "image.loading")})
|
||||
default:
|
||||
frame.Role(ui.RoleImage).Label(msg(c, "image.placeholder")).Description(opts.Alt)
|
||||
Icon(c, "image", IconOptions{Size: 24, Tone: IconMuted})
|
||||
}
|
||||
})
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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", imageTestBitmap(), ImageOptions{Loading: true}, "Loading image"},
|
||||
{"error", imageTestBitmap(), ImageOptions{Loading: true, Err: errors.New("404")}, "Image failed to load"},
|
||||
} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
if Image(c, nil, ImageOptions{Width: 200, Height: 140, Err: errors.New("timeout")}).Retry {
|
||||
retries++
|
||||
}
|
||||
}, 300, 300)
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
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) {
|
||||
Use(c, Settings{})
|
||||
Image(c, nil, ImageOptions{Width: -1})
|
||||
}, 100, 100)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestImageSnapshot(t *testing.T) {
|
||||
bmp := imageTestBitmap()
|
||||
snapshot(t, "image", 600, 180, 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: 160, Height: 140, Err: errors.New("404")})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Fade animates e's background and border colors over d; zero skips it.
|
||||
func Fade(e *ui.Element, d time.Duration) *ui.Element {
|
||||
if d > 0 {
|
||||
e.Transition(ui.ElementTransition{Colors: true, Duration: d})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Rule draws a 1 DIP horizontal line from x0 to x1 at y, on device pixels.
|
||||
func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
|
||||
s := float64(p.Scale())
|
||||
px := math.Floor(float64(y) * s)
|
||||
if int(math.Round(s))%2 == 1 {
|
||||
px += 0.5
|
||||
}
|
||||
yy := float32(px / s)
|
||||
p.Line(x0, yy, x1, yy, 1, c)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Kbd shows a key or key combination as key caps. It registers nothing.
|
||||
// "Mod" is Command on macOS and Ctrl elsewhere; Ctrl, Alt, Shift, Enter,
|
||||
// Esc, Tab, Backspace, Delete, Up, Down, Left and Right follow the platform.
|
||||
func Kbd(c *ui.Context, keys ...string) *ui.Element {
|
||||
if len(keys) == 0 {
|
||||
panic("mujica: Kbd needs at least one key")
|
||||
}
|
||||
k := 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(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
}
|
||||
face, _ := kbdKey(key, mac)
|
||||
ui.Box(c).Key(i).Row().Center().MinWidth(20).Height(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(theme.CaptionSize).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
}
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
// kbdNames maps a key to its macOS cap, macOS word and other platforms' 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 how it is read 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,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// LinkOptions configure a Link. An empty URL makes an in-app action link.
|
||||
type LinkOptions struct {
|
||||
// URL opens in the browser, or goes there in a MyGo router for a path.
|
||||
URL string
|
||||
// Error is the caller's open failure, shown below the link.
|
||||
Error string
|
||||
}
|
||||
|
||||
// Link is underlined text activated by a click or Enter. Clicked reports
|
||||
// every activation. A malformed URL is not opened and shows an error.
|
||||
func Link(c *ui.Context, label string, opts LinkOptions) *ui.Element {
|
||||
if label == "" {
|
||||
panic("mujica: Link needs a label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := 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(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(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 whether it can be opened.
|
||||
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,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestLinkOpensURL(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{Locale: 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://"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"editable_text.saving": {"Saving", "正在保存"},
|
||||
"icon.missing": {"Missing icon: %s", "缺少图标:%s"},
|
||||
"image.placeholder": {"No image", "暂无图片"},
|
||||
"image.loading": {"Loading image", "正在加载图片"},
|
||||
"image.error": {"Image failed to load", "图片加载失败"},
|
||||
"image.retry": {"Retry", "重试"},
|
||||
"avatar.unnamed": {"Unnamed", "未命名"},
|
||||
"avatar_group.more": {"Show all %d people", "显示全部 %d 人"},
|
||||
"tag.remove": {"Remove %s", "移除%s"},
|
||||
"link.invalid": {"This link cannot be opened: %s", "无法打开此链接:%s"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TagOptions configure a Tag. The zero value is a plain label.
|
||||
type TagOptions struct {
|
||||
// Icon names an icon of the icons package 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 what the user did to it this frame.
|
||||
type TagView struct {
|
||||
Element *ui.Element
|
||||
// Clicked reports the label was activated; never set by the remove button.
|
||||
Clicked bool
|
||||
// Closed reports the remove button, or Delete or Backspace on the label.
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Tag shows a short label. Clicking the label and removing the tag are
|
||||
// separate actions with separate focus stops.
|
||||
func Tag(c *ui.Context, label string, opts TagOptions) TagView {
|
||||
if label == "" {
|
||||
panic("mujica: Tag needs a label")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := 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)
|
||||
}
|
||||
internal.Fade(main, Motion(c, theme.StateDuration))
|
||||
}
|
||||
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(theme.BodySize - 1).SingleLine()
|
||||
})
|
||||
if opts.Clickable && main.Clicked() {
|
||||
v.Clicked = true
|
||||
}
|
||||
if opts.Closable {
|
||||
if opts.Clickable && !opts.Disabled && (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(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)
|
||||
internal.Fade(b, Motion(c, theme.StateDuration))
|
||||
b.TextColor(fg).Children(func() {
|
||||
Icon(c, "x", IconOptions{Size: 12})
|
||||
})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type tagCounts struct{ clicked, closed int }
|
||||
|
||||
func tagTester(opts TagOptions, n *tagCounts) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TextOptions configure a Text. The zero value wraps every line.
|
||||
type TextOptions struct {
|
||||
// MaxLines cuts the text with an ellipsis after that many lines; zero never cuts.
|
||||
MaxLines int
|
||||
// Selectable lets the user select and copy the text.
|
||||
Selectable bool
|
||||
}
|
||||
|
||||
// Text shows body text that wraps at the width it gets.
|
||||
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()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// EditableText is Text's editable mode. Double click or Enter edits; Enter
|
||||
// or leaving confirms, Escape cancels. Changed reports a confirmed new value.
|
||||
func EditableText(c *ui.Context, value *string, opts EditableTextOptions) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
line := k.ControlBorder
|
||||
if opts.Error != "" {
|
||||
line = k.Danger
|
||||
}
|
||||
var box *ui.Element
|
||||
ui.Column(c).Gap(u).AlignItems(ui.Start).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Saving).Children(func() {
|
||||
box = ui.EditableText(c, value)
|
||||
pad := u / 2
|
||||
box.Padding(pad+1, pad*2+1).MinWidth(theme.MinHitSize*2).MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).
|
||||
BorderWidth(0, 0, 1, 0).BorderColor(line).BorderStyle(ui.BorderDashed)
|
||||
internal.Ring(box, k.Focus, theme.ControlRadius)
|
||||
if box.Hovered() {
|
||||
box.Background(k.SurfaceHover)
|
||||
}
|
||||
internal.Fade(box, Motion(c, theme.StateDuration))
|
||||
if opts.Label != "" {
|
||||
box.Label(opts.Label)
|
||||
}
|
||||
if opts.Saving {
|
||||
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "editable_text.saving")})
|
||||
}
|
||||
})
|
||||
if opts.Error != "" {
|
||||
box.Error(opts.Error)
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize)
|
||||
}
|
||||
})
|
||||
if box.Changed() {
|
||||
slog.Debug("mujica: editabletext changed", "len", len(*value))
|
||||
}
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestTextSelectAndCopy(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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 TestTextNotSelectableByDefault(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, 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) {
|
||||
Use(c, Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{Label: "File name"}).Changed() {
|
||||
changed++
|
||||
}
|
||||
}, 300, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Type("summary")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if name != "summary.pdf" || changed != 1 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableTextCancel(t *testing.T) {
|
||||
name := "report.pdf"
|
||||
changed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if EditableText(c, &name, EditableTextOptions{}).Changed() {
|
||||
changed++
|
||||
}
|
||||
}, 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 {
|
||||
t.Fatalf("name %q, changed %d", name, changed)
|
||||
}
|
||||
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) {
|
||||
Use(c, 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})
|
||||
})
|
||||
})
|
||||
}
|
||||