Elements are now pass-scoped value handles: tests that read bounds after a frame bind a ui.Handle. pixel tells frames apart by the build pass's root, since the Tester clock no longer moves on its own. Fix the regressions the upgrade brought: - Clipboard reads in input handlers go through c.Services(). - MyGo notes an overlay's opener after building it, so AutoFocus inside lost it; pixel.AutoFocus focuses through a Handle, and archtest bans the raw call. Dialogs hand the focus back again. - Elements around a press stay hovered: Tooltip tells a press-given focus by focus arriving under the pointer. - The web host moves the Tester clock with real time, at most 500 ms a call, and gives components the real clock. The gallery's source index also accepts CRLF checkouts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
457 lines
12 KiB
Go
457 lines
12 KiB
Go
package layout_test
|
||
|
||
import (
|
||
"image"
|
||
"image/color"
|
||
"slices"
|
||
"testing"
|
||
|
||
"github.com/egoist/mygo/ui"
|
||
|
||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
|
||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||
)
|
||
|
||
func use(c *ui.Context) { pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay}) }
|
||
|
||
func box(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
||
t.Helper()
|
||
r, ok := tt.Find(label)
|
||
if !ok {
|
||
t.Fatalf("no %q in %q", label, tt.Texts())
|
||
}
|
||
return r
|
||
}
|
||
|
||
func dragBy(t *testing.T, tt *ui.Tester, label string, dx, dy float32) {
|
||
t.Helper()
|
||
r := box(t, tt, label)
|
||
x, y := r.X+r.W/2, r.Y+r.H/2
|
||
tt.Press(x, y)
|
||
tt.Move(x+dx, y+dy)
|
||
tt.Release(x+dx, y+dy)
|
||
}
|
||
|
||
func TestSplitDragKeysClamp(t *testing.T) {
|
||
size, changes := 100, 0
|
||
opts := layout.SplitOptions{Label: "Divide"}
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
r := layout.Split(c, &size, opts, func() {}, func() {})
|
||
r.Element.Size(400, 100)
|
||
if r.Changed {
|
||
changes++
|
||
}
|
||
}, 400, 100)
|
||
dragBy(t, tt, "Divide", 20, 0)
|
||
if size != 110 || changes == 0 {
|
||
t.Fatalf("drag of 10 dots left size %d, %d changes", size, changes)
|
||
}
|
||
tt.Key(0, ui.KeyRight)
|
||
tt.Key(0, ui.KeyRight)
|
||
tt.Key(0, ui.KeyLeft)
|
||
if size != 114 {
|
||
t.Fatalf("keys left size %d, want 114", size)
|
||
}
|
||
size = 1000
|
||
tt.Frame()
|
||
if size != 200-6-24 {
|
||
t.Fatalf("size clamped to %d, want %d", size, 200-6-24)
|
||
}
|
||
size = 3
|
||
tt.Frame()
|
||
if size != 24 {
|
||
t.Fatalf("size clamped to %d, want the minimum 24", size)
|
||
}
|
||
opts.Disabled = true
|
||
dragBy(t, tt, "Divide", 40, 0)
|
||
if size != 24 {
|
||
t.Fatalf("a disabled grip moved to %d", size)
|
||
}
|
||
}
|
||
|
||
func TestSplitRelaysOutWhenItsRoomChanges(t *testing.T) {
|
||
size := 1000
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.Split(c, &size, layout.SplitOptions{}, func() {}, func() { ui.Box(c).Fill().Label("SECOND") }).Element.Fill()
|
||
}, 400, 100)
|
||
if second := box(t, tt, "SECOND"); size != 200-6-24 || second.W != 48 {
|
||
t.Fatalf("first frames left size %d and the second pane %v", size, second)
|
||
}
|
||
tt.SetSize(240, 100)
|
||
if second := box(t, tt, "SECOND"); size != 120-6-24 || second.W != 48 {
|
||
t.Fatalf("after shrinking, size %d and the second pane %v", size, second)
|
||
}
|
||
}
|
||
|
||
func TestScrollAreaArrowsTrackThumb(t *testing.T) {
|
||
s := ui.ScrollState{}
|
||
opts := layout.ScrollAreaOptions{Label: "List"}
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.ScrollArea(c, &s, opts, func() {
|
||
for range 40 {
|
||
ui.Box(c).Height(32)
|
||
}
|
||
}).Element.Size(200, 100)
|
||
}, 300, 200)
|
||
tt.Frame()
|
||
if s.MaxY != 40*32-100 {
|
||
t.Fatalf("MaxY %v", s.MaxY)
|
||
}
|
||
click := func(label string) {
|
||
if err := tt.Click(label); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
click("SCROLL DOWN")
|
||
if s.Y != 32 {
|
||
t.Fatalf("down arrow scrolled to %v, want 16 dots", s.Y)
|
||
}
|
||
click("SCROLL UP")
|
||
click("SCROLL UP")
|
||
if s.Y != 0 {
|
||
t.Fatalf("up arrow scrolled to %v, want 0", s.Y)
|
||
}
|
||
up := box(t, tt, "SCROLL UP")
|
||
tt.ClickAt(up.X+up.W/2, 100-up.H-4)
|
||
if s.Y != 100 {
|
||
t.Fatalf("track below the thumb paged to %v, want 100", s.Y)
|
||
}
|
||
// Track 60 DIPs, thumb 16: one thumb DIP scrolls MaxY/44.
|
||
at := up.Y + up.H + 2
|
||
tt.Press(up.X+up.W/2, at+4)
|
||
tt.Move(up.X+up.W/2, at+4+11)
|
||
tt.Release(up.X+up.W/2, at+4+11)
|
||
if want := float32(100) + 11*s.MaxY/44; s.Y != want {
|
||
t.Fatalf("thumb drag scrolled to %v, want %v", s.Y, want)
|
||
}
|
||
tt.Key(0, ui.KeyTab)
|
||
if !tt.Focused("List") {
|
||
t.Fatal("Tab did not reach the scroll view")
|
||
}
|
||
onPalette(t, tt.Image())
|
||
opts.Disabled = true
|
||
tt.Frame()
|
||
if tt.Focused("List") {
|
||
t.Fatal("a disabled scroll area kept the focus")
|
||
}
|
||
frozen := s.Y
|
||
_ = tt.Click("SCROLL DOWN")
|
||
tt.Scroll(50, 50, 0, 80)
|
||
tt.Key(0, ui.KeyDown)
|
||
tt.Move(50, 50)
|
||
tt.Key(0, ui.KeyPageDown)
|
||
if s.Y != frozen {
|
||
t.Fatalf("a disabled scroll area moved from %v to %v", frozen, s.Y)
|
||
}
|
||
}
|
||
|
||
// onPalette fails on any pixel off the Spring Day palette.
|
||
func onPalette(t *testing.T, img *image.RGBA) {
|
||
t.Helper()
|
||
ok := map[color.RGBA]bool{}
|
||
for _, c := range pixel.SpringDay.Colors {
|
||
ok[color.RGBA{c.R, c.G, c.B, 255}] = true
|
||
}
|
||
for y := range img.Rect.Dy() {
|
||
for x := range img.Rect.Dx() {
|
||
if p := img.RGBAAt(x, y); !ok[p] {
|
||
t.Fatalf("pixel %d,%d is %v, off the palette", x, y, p)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTitleBarBoxes(t *testing.T) {
|
||
var closes, minimizes int
|
||
opts := layout.TitleBarOptions{}
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
r := layout.TitleBar(c, "Notes", opts)
|
||
closes += boolInt(r.Close)
|
||
minimizes += boolInt(r.Minimize)
|
||
}, 300, 60)
|
||
for _, l := range []string{"CLOSE", "MINIMIZE", "MINIMIZE"} {
|
||
if err := tt.Click(l); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if closes != 1 || minimizes != 2 {
|
||
t.Fatalf("closes %d minimizes %d", closes, minimizes)
|
||
}
|
||
opts.NoMinimize = true
|
||
tt.Frame()
|
||
if _, ok := tt.Find("MINIMIZE"); ok {
|
||
t.Fatal("NoMinimize still shows the minimize box")
|
||
}
|
||
opts.Disabled = true
|
||
tt.Frame()
|
||
_ = tt.Click("CLOSE")
|
||
if closes != 1 {
|
||
t.Fatal("a disabled close box closed")
|
||
}
|
||
}
|
||
|
||
func TestResizableGripKeysClamp(t *testing.T) {
|
||
w, h, changes := 100, 60, 0
|
||
opts := layout.ResizableOptions{MaxW: 150}
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
if layout.Resizable(c, &w, &h, opts, func() {}).Changed {
|
||
changes++
|
||
}
|
||
}, 600, 400)
|
||
dragBy(t, tt, "RESIZE", 20, 10)
|
||
if w != 110 || h != 65 || changes == 0 {
|
||
t.Fatalf("drag left %d×%d, %d changes", w, h, changes)
|
||
}
|
||
tt.Key(0, ui.KeyRight)
|
||
tt.Key(0, ui.KeyDown)
|
||
if w != 114 || h != 69 {
|
||
t.Fatalf("keys left %d×%d, want 114×69", w, h)
|
||
}
|
||
w, h = 1000, 1
|
||
tt.Frame()
|
||
if w != 150 || h != 24 {
|
||
t.Fatalf("clamped to %d×%d, want 150×24", w, h)
|
||
}
|
||
opts.Disabled = true
|
||
dragBy(t, tt, "RESIZE", -40, 0)
|
||
if w != 150 {
|
||
t.Fatalf("a disabled grip resized to %d", w)
|
||
}
|
||
}
|
||
|
||
func TestHandheldButtons(t *testing.T) {
|
||
pressed := map[string]int{}
|
||
opts := layout.HandheldOptions{ScreenW: 80, ScreenH: 56}
|
||
locale := pixel.En
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: locale})
|
||
r := layout.Handheld(c, opts, func() {})
|
||
for name, on := range map[string]bool{"UP": r.Up, "DOWN": r.Down, "LEFT": r.Left, "RIGHT": r.Right, "A": r.A, "B": r.B} {
|
||
pressed[name] += boolInt(on)
|
||
}
|
||
}, 600, 500)
|
||
names := []string{"UP", "DOWN", "LEFT", "RIGHT", "A", "B"}
|
||
for _, n := range names {
|
||
if err := tt.Click(n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
for _, n := range names {
|
||
if pressed[n] != 1 {
|
||
t.Fatalf("presses %v", pressed)
|
||
}
|
||
}
|
||
locale = pixel.Zh
|
||
tt.Frame()
|
||
if err := tt.Click("左"); err != nil || pressed["LEFT"] != 2 {
|
||
t.Fatalf("Chinese labels: %v, %v", err, pressed)
|
||
}
|
||
opts.Disabled = true
|
||
tt.Frame()
|
||
_ = tt.Click("A")
|
||
if pressed["A"] != 1 {
|
||
t.Fatal("a disabled A button pressed")
|
||
}
|
||
}
|
||
|
||
func TestGridColumnsAreWholeDots(t *testing.T) {
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.Grid(c, 3, layout.GridOptions{Columns: 3, Lines: layout.SolidLines}, func(i int) {
|
||
ui.Box(c).Size(10, 10).Label([]string{"G0", "G1", "G2"}[i])
|
||
}).Width(200)
|
||
}, 300, 100)
|
||
for i, x := range []float32{8, 76, 142} {
|
||
if r := box(t, tt, []string{"G0", "G1", "G2"}[i]); r.X != x {
|
||
t.Fatalf("cell %d content at x %v, want %v", i, r.X, x)
|
||
}
|
||
}
|
||
defer func() {
|
||
if recover() == nil {
|
||
t.Fatal("unknown grid lines did not panic")
|
||
}
|
||
}()
|
||
ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.Grid(c, 1, layout.GridOptions{Lines: 9}, func(int) {})
|
||
}, 100, 100)
|
||
}
|
||
|
||
func TestAppFrameFolds(t *testing.T) {
|
||
text := func(c *ui.Context, s string) func() { return func() { pixel.Text(c, s, pixel.TextOptions{}) } }
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.AppFrame(c, layout.AppFrameOptions{}, nil, text(c, "SIDE"), text(c, "MAIN"), nil)
|
||
}, 800, 300)
|
||
tt.Frame()
|
||
side, main := box(t, tt, "SIDE"), box(t, tt, "MAIN")
|
||
if side.X+side.W > main.X {
|
||
t.Fatalf("wide frame: side %v not left of main %v", side, main)
|
||
}
|
||
tt.SetSize(500, 300)
|
||
tt.Frame()
|
||
side, main = box(t, tt, "SIDE"), box(t, tt, "MAIN")
|
||
if side.Y+side.H > main.Y {
|
||
t.Fatalf("narrow frame did not fold: side %v not above main %v", side, main)
|
||
}
|
||
}
|
||
|
||
func TestAspectBoxAndViewport(t *testing.T) {
|
||
var aspect, screen ui.Handle
|
||
var ctx *ui.Context
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
ctx = c
|
||
use(c)
|
||
ui.Column(c).Children(func() {
|
||
ui.Box(c).Width(300).Children(func() {
|
||
layout.AspectBox(c, layout.AspectBoxOptions{W: 16, H: 9}, func() {}).Bind(&aspect)
|
||
})
|
||
ui.Box(c).Size(400, 300).Children(func() {
|
||
layout.Viewport(c, layout.ViewportOptions{W: 64, H: 40}, func() { ui.Box(c).Fill().Bind(&screen) })
|
||
})
|
||
})
|
||
}, 500, 700)
|
||
tt.Frame()
|
||
if b := aspect.Bounds(ctx); b.W != 300 || b.H != 2*(150*9/16) {
|
||
t.Fatalf("aspect box %v", b)
|
||
}
|
||
if b := screen.Bounds(ctx); b.X != 72 || b.Y != aspect.Bounds(ctx).H+70 || b.W != 256 || b.H != 160 {
|
||
t.Fatalf("viewport screen %v", b)
|
||
}
|
||
}
|
||
|
||
func TestLayersSizes(t *testing.T) {
|
||
var auto, sized, fill ui.Handle
|
||
var ctx *ui.Context
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
ctx = c
|
||
use(c)
|
||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||
layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Size(50, 30) }, func() { ui.Box(c).Fill() }).Bind(&auto)
|
||
layout.Layers(c, layout.LayersOptions{}, func() { ui.Box(c).Fill().Bind(&fill) }).Size(120, 64).Bind(&sized)
|
||
})
|
||
}, 400, 300)
|
||
tt.Frame()
|
||
if b := auto.Bounds(ctx); b.W != 50 || b.H != 30 {
|
||
t.Fatalf("first layer did not size the box: %v", b)
|
||
}
|
||
if b := fill.Bounds(ctx); b.W != 120 || b.H != 64 {
|
||
t.Fatalf("first layer did not fill a sized box: %v", b)
|
||
}
|
||
if b := sized.Bounds(ctx); b.H != 64 {
|
||
t.Fatalf("sized box %v", b)
|
||
}
|
||
}
|
||
|
||
func TestTitledContainersAndOrder(t *testing.T) {
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.Window(c, layout.WindowOptions{Title: "ITEMS"}, func() {})
|
||
layout.Section(c, "Sound", layout.SectionOptions{}, func() {})
|
||
layout.Card(c, layout.CardOptions{Title: "QUEST", Footer: func() { pixel.Text(c, "FOOT", pixel.TextOptions{}) }}, func() {
|
||
pixel.Text(c, "BODY", pixel.TextOptions{})
|
||
})
|
||
layout.StatusBar(c, []string{"Ready", "Ln 1"}, layout.StatusBarOptions{})
|
||
}, 400, 400)
|
||
got := tt.Texts()
|
||
for _, l := range []string{"ITEMS", "Sound", "QUEST", "Ready", "Ln 1"} {
|
||
if !slices.Contains(got, l) {
|
||
t.Fatalf("no %q in %q", l, got)
|
||
}
|
||
}
|
||
if slices.Index(got, "BODY") > slices.Index(got, "FOOT") || box(t, tt, "BODY").Y >= box(t, tt, "FOOT").Y {
|
||
t.Fatalf("card footer came before its body: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDividerStylePanics(t *testing.T) {
|
||
defer func() {
|
||
if recover() == nil {
|
||
t.Fatal("unknown divider style did not panic")
|
||
}
|
||
}()
|
||
ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
layout.Divider(c, layout.DividerOptions{Style: 9})
|
||
}, 100, 100)
|
||
}
|
||
|
||
func boolInt(b bool) int {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func TestScrollAreaHoldsStillWhenInert(t *testing.T) {
|
||
s, inert := ui.ScrollState{}, false
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
|
||
layout.ScrollArea(c, &s, layout.ScrollAreaOptions{Label: "List"}, func() {
|
||
for range 40 {
|
||
ui.Box(c).Height(32)
|
||
}
|
||
}).Element.Size(200, 100)
|
||
}, 300, 200)
|
||
tt.Frame()
|
||
r := box(t, tt, "List")
|
||
if err := tt.Click("List"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bar := func() []uint8 {
|
||
return tt.Image().SubImage(image.Rect(int(r.X+r.W), int(r.Y), int(r.X+r.W)+20, int(r.Y+r.H))).(*image.RGBA).Pix
|
||
}
|
||
looks := bar()
|
||
inert = true
|
||
tt.Frame()
|
||
// Inert keeps the look; only Disabled dims the bar.
|
||
if string(bar()) != string(looks) {
|
||
t.Fatal("an inert scroll area changed its look")
|
||
}
|
||
// Keys, the wheel and the arrows all leave it be.
|
||
tt.Key(0, ui.KeyDown)
|
||
tt.Move(r.X+20, r.Y+30)
|
||
tt.Scroll(r.X+20, r.Y+30, 0, 80)
|
||
if err := tt.Click("SCROLL DOWN"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if s.Y != 0 {
|
||
t.Fatalf("an inert scroll area moved to %v", s.Y)
|
||
}
|
||
}
|
||
|
||
func TestInertScrollAreaLetsTabPass(t *testing.T) {
|
||
s, inert, clicks := ui.ScrollState{}, false, 0
|
||
tt := ui.NewTester(func(c *ui.Context) {
|
||
use(c)
|
||
set := pixel.Current(c)
|
||
set.Inert = inert
|
||
pixel.Scope(c, set, func() {
|
||
layout.ScrollArea(c, &s, layout.ScrollAreaOptions{Label: "List"}, func() {
|
||
for range 40 {
|
||
ui.Box(c).Height(32)
|
||
}
|
||
}).Element.Size(200, 100)
|
||
})
|
||
if ui.ButtonBase(c).Label("Next").Size(60, 20).Clicked() {
|
||
clicks++
|
||
}
|
||
}, 300, 200)
|
||
if err := tt.Click("List"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
inert = true
|
||
tt.Frame()
|
||
// Parked on the root, Tab passes the inert view.
|
||
tt.Key(0, ui.KeyTab)
|
||
tt.Key(0, ui.KeyEnter)
|
||
if clicks != 1 {
|
||
t.Fatal("Tab stuck on an inert scroll area")
|
||
}
|
||
}
|