Add layout components 001-020 with demos and behavior tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 18:02:26 -04:00
1 parent 08e19833a2
commit 9a7f593892
29 files changed
+2110 -9

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AppFrameOptions struct {
// SidebarWidth is zero for 96 dots.
SidebarWidth int
// FoldBelow stacks the sidebar over main; zero means 320.
FoldBelow int
}
// AppFrame arranges header, sidebar, main and footer; nil ones vanish.
func AppFrame(c *ui.Context, o AppFrameOptions, header, sidebar, main, footer func()) *ui.Element {
k := pixel.K(c)
sw := orDef("AppFrame SidebarWidth", o.SidebarWidth, 96)
fold := orDef("AppFrame FoldBelow", o.FoldBelow, 320)
if main == nil {
panic("layout: AppFrame needs main")
}
root := ui.Column(c).Fill()
w, check := pixel.WrapWidth(c, root)
folded := w > 0 && w < fold
strip := func(fn func(), below bool) {
ui.Box(c).FillWidth().Padding(k.D(4), k.D(6)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
if below {
g.HLine(0, g.H-1, g.W, pixel.Edge)
} else {
g.HLine(0, 0, g.W, pixel.Edge)
}
}).Children(fn)
}
return root.Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Back)
}).Children(func() {
if header != nil {
strip(header, true)
}
var body *ui.Element
if folded {
body = ui.Column(c)
} else {
body = ui.Row(c)
}
body.Grow(1).AlignItems(ui.Stretch).Children(func() {
if sidebar != nil {
side := ui.Column(c).Padding(k.D(4)).Gap(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Well)
if folded {
g.HLine(0, g.H-1, g.W, pixel.Edge)
} else {
g.VLine(g.W-1, 0, g.H, pixel.Edge)
}
})
if !folded {
side.Width(k.D(sw))
}
side.Children(sidebar)
}
ui.Column(c).Grow(1).Padding(k.D(6)).Gap(k.D(4)).Clip().Children(main)
})
if footer != nil {
strip(footer, false)
}
})
}
+21
View File
@@ -0,0 +1,21 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AspectBoxOptions struct {
// W:H is the ratio; zero sides mean 1.
W, H int
}
// AspectBox fills the width and keeps W:H in whole dots.
func AspectBox(c *ui.Context, o AspectBoxOptions, children func()) *ui.Element {
k := pixel.K(c)
rw, rh := orDef("AspectBox W", o.W, 1), orDef("AspectBox H", o.H, 1)
e := ui.Box(c).FillWidth().Clip()
w, check := pixel.WrapWidth(c, e)
return e.Height(k.D(w * rh / rw)).Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(children)
}
+51
View File
@@ -0,0 +1,51 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CardOptions struct {
Title string
Icon *pixel.Sprite
// Footer builds a strip under the body.
Footer func()
}
// Card is a raised panel: header strip, body, footer.
func Card(c *ui.Context, o CardOptions, body func()) *ui.Element {
k := pixel.K(c)
e := ui.Column(c).Padding(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
box(k.Canvas(p, r), pixel.Raised, pixel.Face)
})
if o.Title != "" {
e.Role(ui.RoleGroup).Label(o.Title)
}
return e.Children(func() {
if o.Title != "" || o.Icon != nil {
cardHeader(c, k, o)
}
ui.Column(c).Padding(k.D(6)).Gap(k.D(4)).Children(body)
if o.Footer != nil {
ui.Row(c).Padding(k.D(5), k.D(6), k.D(4), k.D(6)).Gap(k.D(4)).AlignItems(ui.Center).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.HLine(0, 0, g.W, pixel.Shade)
g.HLine(0, 1, g.W, pixel.Lit)
}).Children(o.Footer)
}
})
}
func cardHeader(c *ui.Context, k pixel.Kit, o CardOptions) {
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight + 4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Accent)
x := 4
if o.Icon != nil {
g.Sprite(x, (g.H-o.Icon.H)/2, o.Icon, 0, pixel.AccentInk, 1)
x += o.Icon.W + 4
}
g.Clip(0, 0, g.W-2, g.H, func() { g.Text(x, 2, o.Title, pixel.AccentInk, 1) })
})
}
+38
View File
@@ -0,0 +1,38 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CRTFrameOptions struct {
NoScanlines bool
// Brand is micro text on the chin.
Brand string
}
const crtSide, crtChin = 10, 16
// CRTFrame shows children on a monitor with scanlines.
func CRTFrame(c *ui.Context, o CRTFrameOptions, children func()) *ui.Element {
k := pixel.K(c)
return ui.Column(c).Padding(k.D(crtSide), k.D(crtSide), k.D(crtChin), k.D(crtSide)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
box(g, pixel.Raised, pixel.Face)
if g.W > 2*crtSide && g.H > crtSide+crtChin {
g.Frame(crtSide-3, crtSide-3, g.W-2*crtSide+6, g.H-crtSide-crtChin+6, pixel.Sunken, pixel.Well)
}
g.Micro(crtSide, g.H-crtChin/2-2, o.Brand, pixel.Shade, 1)
g.Rect(g.W-crtSide-6, g.H-crtChin/2-1, 3, 3, pixel.Good)
}).DrawOver(func(p *ui.Painter, r ui.Rect) {
if o.NoScanlines {
return
}
// Scanlines are Shade at half strength on every other row.
g := k.Canvas(p, r)
for y := crtSide + 1; y < g.H-crtChin; y += 2 {
g.Checker(crtSide, y, g.W-2*crtSide, 1, pixel.Shade)
}
}).Children(children)
}
+95
View File
@@ -0,0 +1,95 @@
package layout
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DividerStyle uint8
const (
Solid DividerStyle = iota
Dotted
Dashed
Double
Ornament
)
type DividerOptions struct {
Vertical bool
Style DividerStyle
// Slot is the ink; zero means Edge.
Slot pixel.Slot
}
var diamond = pixel.MustSprite(`
..#..
.###.
#####
.###.
..#..
`)
// Divider is a rule across its parent.
func Divider(c *ui.Context, o DividerOptions) *ui.Element {
k := pixel.K(c)
ink := o.Slot
if ink == pixel.Ink {
ink = pixel.Edge
}
thick := [...]int{Solid: 1, Dotted: 1, Dashed: 1, Double: 3, Ornament: 5}
if int(o.Style) >= len(thick) {
panic(fmt.Sprintf("layout: unknown divider style %d", o.Style))
}
t := thick[o.Style]
e := ui.Box(c).Role(ui.RoleNone)
if o.Vertical {
e.FillHeight().Width(k.D(t))
} else {
e.FillWidth().Height(k.D(t))
}
return e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
n := g.W
if o.Vertical {
n = g.H
}
line := func(at, from, n int) {
x, y := from, at
if o.Vertical {
x, y = at, from
}
switch o.Style {
case Dotted:
w, h := n, 1
if o.Vertical {
w, h = 1, n
}
g.Checker(x, y, w, h, ink)
case Dashed:
rule(g, x, y, n, o.Vertical, 4, 2, ink)
default:
rule(g, x, y, n, o.Vertical, max(n, 1), 0, ink)
}
}
switch o.Style {
case Double:
line(0, 0, n)
line(2, 0, n)
case Ornament:
mid := n / 2
line(2, 0, mid-4)
line(2, mid+5, n-mid-5)
if o.Vertical {
g.Sprite(0, mid-2, diamond, 0, ink, 1)
} else {
g.Sprite(mid-2, 0, diamond, 0, ink, 1)
}
default:
line(0, 0, n)
}
})
}
+68
View File
@@ -0,0 +1,68 @@
package layout
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type GridLines uint8
const (
NoLines GridLines = iota
SolidLines
DottedLines
)
type GridOptions struct {
// Columns is zero for 2.
Columns int
// Pad is around each cell's content; zero means 4.
Pad int
Lines GridLines
}
// Grid lays n cells in equal columns, lines between them.
func Grid(c *ui.Context, n int, o GridOptions, cell func(i int)) *ui.Element {
k := pixel.K(c)
cols := orDef("Grid Columns", o.Columns, 2)
pad := orDef("Grid Pad", o.Pad, 4)
orDef("Grid n", n, 0)
if o.Lines > DottedLines {
panic(fmt.Sprintf("layout: unknown grid lines %d", o.Lines))
}
grid := ui.Grid(c).FillWidth()
w, check := pixel.WrapWidth(c, grid)
tracks := make([]ui.Track, cols)
for i, d := range shares(w, cols) {
tracks[i] = ui.Fixed(k.D(d))
}
return grid.ColumnTracks(tracks...).Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() {
for i := range n {
right, below := edges(i, n, cols)
ui.Box(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
line := func(x, y, w, h int) {
if o.Lines == DottedLines {
g.Checker(x, y, w, h, pixel.Edge)
} else if o.Lines == SolidLines {
g.Rect(x, y, w, h, pixel.Edge)
}
}
if right {
line(g.W-1, 0, 1, g.H)
}
if below {
line(0, g.H-1, g.W, 1)
}
}).Children(func() { cell(i) })
}
})
}
// edges reports neighbors right of and below cell i.
func edges(i, n, cols int) (right, below bool) {
return i%cols != cols-1 && i != n-1, i < (n-1)/cols*cols
}
+95
View File
@@ -0,0 +1,95 @@
package layout
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HandheldOptions struct {
// ScreenW×ScreenH is the screen in dots; zero means 160×144.
ScreenW, ScreenH int
// Brand is micro text on the body.
Brand string
Label string
Disabled bool
}
type HandheldResult struct {
Element *ui.Element
Up, Down, Left, Right, A, B bool
}
const bezel = 8
// Handheld is a pocket console around screen; buttons report presses.
func Handheld(c *ui.Context, o HandheldOptions, screen func()) HandheldResult {
k := pixel.K(c)
sw, sh := orDef("Handheld ScreenW", o.ScreenW, 160), orDef("Handheld ScreenH", o.ScreenH, 144)
res := HandheldResult{}
res.Element = ui.Column(c).Width(k.D(sw + 2*bezel + 40)).Padding(k.D(10)).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(o.Label).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
box(g, pixel.Raised, pixel.Face)
g.Micro(10, g.H-9, o.Brand, pixel.Shade, 1)
}).Children(func() {
ui.Box(c).Padding(k.D(bezel), k.D(bezel+10), k.D(bezel), k.D(bezel+10)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
box(g, pixel.Sunken, pixel.Shade)
led := pixel.Good
if o.Disabled {
led = pixel.Faint
}
g.Rect(5, g.H/2-1, 3, 3, led)
}).Children(func() {
ui.Box(c).Size(k.D(sw), k.D(sh)).Children(screen)
})
ui.Box(c).Width(k.D(sw + 2*bezel + 20)).Height(k.D(44)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(15, 18, 9, 9, pixel.Edge)
}).Children(func() {
pad := func(name string, x, y, w, h int) bool { return key(c, k, name, x, y, w, h, false, o.Disabled) }
res.Up = pad(k.Msg("up"), 15, 7, 9, 11)
res.Left = pad(k.Msg("left"), 4, 18, 11, 9)
res.Right = pad(k.Msg("right"), 24, 18, 11, 9)
res.Down = pad(k.Msg("down"), 15, 27, 9, 11)
right := sw + 2*bezel + 20
res.B = key(c, k, "B", right-44, 18, 15, 15, true, o.Disabled)
res.A = key(c, k, "A", right-24, 10, 15, 15, true, o.Disabled)
})
})
for name, on := range map[string]bool{"up": res.Up, "down": res.Down, "left": res.Left, "right": res.Right, "a": res.A, "b": res.B} {
if on {
slog.Debug("8bit: handheld press", "button", name)
}
}
return res
}
// key is a d-pad arm, or a round button.
func key(c *ui.Context, k pixel.Kit, name string, x, y, w, h int, round, disabled bool) bool {
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name).Disabled(disabled) })
shell.Absolute().Left(k.D(x)).Top(k.D(y)).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
dy := 0
if st.Press {
dy = 1
}
if round {
g.Disc(w/2, h/2, w/2, pixel.Edge)
g.Disc(w/2, h/2-1+dy, w/2-1, pixel.Accent)
g.Micro(w/2-1, h/2-3+dy, name, pixel.AccentInk, 1)
} else {
g.Rect(0, dy, g.W, g.H-dy, pixel.Edge)
g.Rect(1, 1+dy, g.W-2, 1, pixel.Faint)
}
if st.Disabled {
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
})
return base.Clicked()
}
+90
View File
@@ -0,0 +1,90 @@
package layout
import (
"reflect"
"testing"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func panics(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatalf("%s did not panic", name)
}
}()
fn()
}
func TestOrDef(t *testing.T) {
if orDef("x", 0, 7) != 7 || orDef("x", 3, 7) != 3 {
t.Fatal("orDef did not default zero or keep a value")
}
panics(t, "negative option", func() { orDef("x", -1, 7) })
panics(t, "unknown frame style", func() { border(pixel.FrameStyle(99)) })
}
func TestEdges(t *testing.T) {
type e struct{ right, below bool }
want := []e{{true, true}, {true, true}, {false, true}, {true, false}, {false, false}}
for i, w := range want {
if r, b := edges(i, 5, 3); r != w.right || b != w.below {
t.Errorf("cell %d of 5 in 3 columns: right %v below %v", i, r, b)
}
}
}
func TestLanes(t *testing.T) {
got := lanes([]int{40, 24, 56, 32, 24, 48, 32, 40}, 3, 4)
want := [][]int{{0, 4, 7}, {1, 3, 6}, {2, 5}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("lanes %v, want %v", got, want)
}
panics(t, "zero height", func() { lanes([]int{8, 0}, 2, 4) })
}
func TestShares(t *testing.T) {
for _, tc := range []struct {
total, n int
want []int
}{{10, 3, []int{4, 3, 3}}, {9, 3, []int{3, 3, 3}}, {-5, 2, []int{0, 0}}} {
if got := shares(tc.total, tc.n); !reflect.DeepEqual(got, tc.want) {
t.Errorf("shares(%d, %d) = %v", tc.total, tc.n, got)
}
}
}
func TestThumb(t *testing.T) {
for _, tc := range []struct {
h, view, y, maxY, size, at float32
}{
{60, 100, 0, 1180, 16, 0},
{60, 100, 1180, 1180, 16, 44},
{10, 50, 30, 1000, 10, 0},
{60, 100, 0, 0, 60, 0},
{100, 100, 50, 100, 50, 24},
} {
if size, at := thumb(tc.h, tc.view, tc.y, tc.maxY, 2); size != tc.size || at != tc.at {
t.Errorf("thumb(h %v view %v y %v max %v) = %v at %v, want %v at %v", tc.h, tc.view, tc.y, tc.maxY, size, at, tc.size, tc.at)
}
}
}
func TestFit(t *testing.T) {
for _, tc := range []struct {
w, h int
roomW, roomH float32
base, want int
}{
{64, 40, 256, 200, 2, 4},
{64, 40, 200, 200, 2, 2},
{10, 10, 0, 0, 1, 1},
{160, 144, 1000, 1000, 1, 4},
{160, 144, 400, 300, 1, 2},
} {
if got := fit(tc.w, tc.h, tc.roomW, tc.roomH, tc.base); got != tc.want {
t.Errorf("fit(%d×%d in %v×%v from %d) = %d, want %d", tc.w, tc.h, tc.roomW, tc.roomH, tc.base, got, tc.want)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
package layout
import "github.com/egoist/mygo/ui"
// Layers stacks layers in a box; the first sizes it.
func Layers(c *ui.Context, layers ...func()) *ui.Element {
if len(layers) == 0 {
panic("layout: Layers needs a layer")
}
return ui.Box(c).Children(func() {
for i, l := range layers {
b := ui.Box(c).Fill()
if i > 0 {
b.Absolute().Top(0).Left(0)
}
b.Children(l)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
// Package layout arranges screens, windows and panes on dots.
package layout
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// orDef is v, or def for zero; negative panics.
func orDef(name string, v, def int) int {
if v < 0 {
panic(fmt.Sprintf("layout: %s %d is negative", name, v))
}
if v == 0 {
return def
}
return v
}
// box frames the canvas unless it is too small.
func box(g pixel.Canvas, style pixel.FrameStyle, fill pixel.Slot) {
if g.W >= 3 && g.H >= 3 {
g.Frame(0, 0, g.W, g.H, style, fill)
}
}
// rule draws n dots on an axis in on/off runs.
func rule(g pixel.Canvas, x, y, n int, vertical bool, on, off int, s pixel.Slot) {
for i := 0; i < n; i += on + off {
run := min(on, n-i)
if vertical {
g.VLine(x, y+i, run, s)
} else {
g.HLine(x+i, y, run, s)
}
}
}
type dragKey struct{}
// drag is enabled e's pointer travel this frame, in dots.
func drag(k pixel.Kit, e *ui.Element) (dx, dy int, ok bool) {
rest := ui.Local(e, dragKey{}, func() [2]float32 { return [2]float32{} })
fx, fy, ok := e.Dragged()
if !ok || e.IsDisabled() {
*rest = [2]float32{}
return 0, 0, false
}
x, y := rest[0]+fx/k.Dot(), rest[1]+fy/k.Dot()
dx, dy = int(x), int(y)
*rest = [2]float32{x - float32(dx), y - float32(dy)}
return dx, dy, true
}
// label is o's label, or the built-in copy for key.
func label(k pixel.Kit, s, key string) string {
if s != "" {
return s
}
return k.Msg(key)
}
+385
View File
@@ -0,0 +1,385 @@
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.Element
tt := ui.NewTester(func(c *ui.Context) {
use(c)
ui.Column(c).Children(func() {
ui.Box(c).Width(300).Children(func() {
aspect = layout.AspectBox(c, layout.AspectBoxOptions{W: 16, H: 9}, func() {})
})
ui.Box(c).Size(400, 300).Children(func() {
layout.Viewport(c, layout.ViewportOptions{W: 64, H: 40}, func() { screen = ui.Box(c).Fill() })
})
})
}, 500, 700)
tt.Frame()
if b := aspect.Bounds(); b.W != 300 || b.H != 2*(150*9/16) {
t.Fatalf("aspect box %v", b)
}
if b := screen.Bounds(); b.X != 72 || b.Y != aspect.Bounds().H+70 || b.W != 256 || b.H != 160 {
t.Fatalf("viewport screen %v", b)
}
}
func TestLayersSizes(t *testing.T) {
var auto, sized, fill *ui.Element
tt := ui.NewTester(func(c *ui.Context) {
use(c)
ui.Column(c).AlignItems(ui.Start).Children(func() {
auto = layout.Layers(c, func() { ui.Box(c).Size(50, 30) }, func() { ui.Box(c).Fill() })
sized = layout.Layers(c, func() { fill = ui.Box(c).Fill() }).Size(120, 64)
})
}, 400, 300)
tt.Frame()
if b := auto.Bounds(); b.W != 50 || b.H != 30 {
t.Fatalf("first layer did not size the box: %v", b)
}
if b := fill.Bounds(); b.W != 120 || b.H != 64 {
t.Fatalf("first layer did not fill a sized box: %v", b)
}
if b := sized.Bounds(); 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, "Ready", "Ln 1")
}, 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
}
+67
View File
@@ -0,0 +1,67 @@
package layout
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MasonryOptions struct {
// Columns is zero for 3.
Columns int
// Gap is zero for 4.
Gap int
}
// Masonry puts each item into the shortest column.
func Masonry(c *ui.Context, heights []int, o MasonryOptions, item func(i int)) *ui.Element {
k := pixel.K(c)
gap := orDef("Masonry Gap", o.Gap, 4)
n := orDef("Masonry Columns", o.Columns, 3)
cols := lanes(heights, n, gap)
row := ui.Row(c).FillWidth().Gap(k.D(gap)).AlignItems(ui.Start)
w, check := pixel.WrapWidth(c, row)
widths := shares(w-gap*(n-1), n)
return row.Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() {
for j, lane := range cols {
ui.Column(c).Width(k.D(widths[j])).Gap(k.D(gap)).Children(func() {
for _, i := range lane {
ui.Box(c).FillWidth().Height(k.D(heights[i])).Clip().Children(func() { item(i) })
}
})
}
})
}
// shares splits total into n whole parts, the first larger.
func shares(total, n int) []int {
out := make([]int, n)
for i := range out {
out[i] = max(0, total/n)
if i < total%n {
out[i]++
}
}
return out
}
// lanes deals items into n columns, shortest first.
func lanes(heights []int, n, gap int) [][]int {
out, tall := make([][]int, n), make([]int, n)
for i, h := range heights {
if h <= 0 {
panic(fmt.Sprintf("layout: Masonry height %d at %d", h, i))
}
j := 0
for k := range tall {
if tall[k] < tall[j] {
j = k
}
}
out[j] = append(out[j], i)
tall[j] += h + gap
}
return out
}
+41
View File
@@ -0,0 +1,41 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PanelOptions struct {
Style pixel.FrameStyle
// Fill is the body slot; zero means Face.
Fill pixel.Slot
// Padding is inside the border; zero means 4.
Padding int
}
// Panel frames children in a style.
func Panel(c *ui.Context, o PanelOptions, children func()) *ui.Element {
k := pixel.K(c)
fill := o.Fill
if fill == pixel.Ink {
fill = pixel.Face
}
pad := border(o.Style) + orDef("Panel Padding", o.Padding, 4)
return ui.Column(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) {
box(k.Canvas(p, r), o.Style, fill)
}).Children(children)
}
// border is the dots a frame style takes per side.
func border(s pixel.FrameStyle) int {
switch s {
case pixel.Flat, pixel.Notched:
return 1
case pixel.Raised, pixel.Sunken:
return 2
case pixel.Double, pixel.Window:
return 3
}
panic("layout: unknown frame style")
}
+83
View File
@@ -0,0 +1,83 @@
package layout
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ResizableOptions struct {
// MinW and MinH are zero for 24.
MinW, MinH int
// MaxW and MaxH are zero for no limit.
MaxW, MaxH int
Label string
Disabled bool
}
type ResizableResult struct {
Element *ui.Element
Changed bool
}
// Resizable is a *w×*h dot box with a ◢ grip.
func Resizable(c *ui.Context, w, h *int, o ResizableOptions, children func()) ResizableResult {
k := pixel.K(c)
minW, minH := orDef("Resizable MinW", o.MinW, 24), orDef("Resizable MinH", o.MinH, 24)
maxW, maxH := orDef("Resizable MaxW", o.MaxW, 0), orDef("Resizable MaxH", o.MaxH, 0)
if maxW > 0 && maxW < minW || maxH > 0 && maxH < minH {
panic(fmt.Sprintf("layout: Resizable max %d×%d under min %d×%d", maxW, maxH, minW, minH))
}
clamp := func(v, lo, hi int) int {
if hi > 0 {
v = min(v, hi)
}
return max(v, lo)
}
was := [2]int{*w, *h}
e := ui.Box(c).Clip()
e.Children(func() {
children()
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSplitter).Label(label(k, o.Label, "resize")).Disabled(o.Disabled).Cursor(ui.CursorResizeNWSE)
})
if dx, dy, ok := drag(k, base); ok {
*w, *h = *w+dx, *h+dy
}
for key, d := range map[ui.Key][2]int{ui.KeyLeft: {-4, 0}, ui.KeyRight: {4, 0}, ui.KeyUp: {0, -4}, ui.KeyDown: {0, 4}} {
if !o.Disabled && base.Shortcut(0, key) {
*w, *h = *w+d[0], *h+d[1]
}
}
shell.Absolute().Right(0).Bottom(0).Size(k.D(9), k.D(9)).Draw(func(p *ui.Painter, r ui.Rect) { corner(k.Canvas(p, r), st) })
})
*w, *h = clamp(*w, minW, maxW), clamp(*h, minH, maxH)
e.Size(k.D(*w), k.D(*h))
res := ResizableResult{Element: e, Changed: [2]int{*w, *h} != was}
if res.Changed {
slog.Debug("8bit: resizable", "w", *w, "h", *h)
}
return res
}
// corner is a ◢ of three ridges in the bottom-right.
func corner(g pixel.Canvas, st pixel.State) {
for _, s := range []int{2, 5, 8} {
for i := 0; i <= s; i++ {
x, y := g.W-1-s+i, g.H-1-i
g.Dot(x, y, pixel.Shade)
if x > 0 && y > 0 {
g.Dot(x-1, y-1, pixel.Lit)
}
}
}
if st.Disabled {
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
}
+38
View File
@@ -0,0 +1,38 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ScreenOptions struct {
// Vignette dithers Shade into the edges.
Vignette bool
Padding int
}
// Screen fills the window with Back; children form a column.
func Screen(c *ui.Context, o ScreenOptions, children func()) *ui.Element {
k := pixel.K(c)
pad := orDef("Screen Padding", o.Padding, 0)
return ui.Column(c).Fill().Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Back)
if o.Vignette {
vignette(g)
}
}).Children(children)
}
// vignette darkens four bands toward the edges.
func vignette(g pixel.Canvas) {
band := max(2, min(g.W, g.H)/32)
for i := range 4 {
in, lv := i*band, 8-2*i
g.Veil(in, in, g.W-2*in, band, pixel.Shade, lv)
g.Veil(in, g.H-in-band, g.W-2*in, band, pixel.Shade, lv)
g.Veil(in, in+band, band, g.H-2*in-2*band, pixel.Shade, lv)
g.Veil(g.W-in-band, in+band, band, g.H-2*in-2*band, pixel.Shade, lv)
}
}
+152
View File
@@ -0,0 +1,152 @@
package layout
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
const barW = 10
var (
upArrow = pixel.MustSprite(`
..#..
.###.
#####
`)
downArrow = pixel.MustSprite(`
#####
.###.
..#..
`)
)
type ScrollAreaOptions struct {
// Label names the region for assistive technology.
Label string
Disabled bool
}
type ScrollAreaResult struct {
Element *ui.Element
// Changed reports the view moved since the last frame.
Changed bool
}
type scrollKey struct{}
// ScrollArea scrolls children vertically beside a pixel scrollbar.
func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children func()) ScrollAreaResult {
k := pixel.K(c)
// The bar comes first: TrackScroll reads s when called.
row := ui.Row(c).AlignItems(ui.Stretch).Reverse()
last := ui.Local(row, scrollKey{}, func() float32 { return s.Y })
view := row.Bounds().H
// Disabled freezes the view: any move is undone before paint.
if o.Disabled {
s.Y = *last
}
var v *ui.Element
row.Children(func() {
scrollBar(c, k, s, view, o.Disabled)
// A disabled view is a new element, so the focus leaves it.
v = ui.Scroll(c).Key(o.Disabled).TrackScroll(s).Grow(1).Label(o.Label).FocusRing(false)
if o.Disabled {
v.Role(ui.RoleGroup).Description(k.Msg("disabled"))
} else {
v.Role(ui.RoleScroll).Focusable()
}
v.Children(children)
})
focus := v.FocusVisible()
row.Draw(func(p *ui.Painter, r ui.Rect) {
if r.H != view {
c.Invalidate()
}
}).DrawOver(func(p *ui.Painter, r ui.Rect) {
if focus {
g := k.Canvas(p, r)
w := g.W - barW
g.Checker(0, 0, w, 1, pixel.Accent)
g.Checker(0, g.H-1, w, 1, pixel.Accent)
g.Checker(0, 0, 1, g.H, pixel.Accent)
g.Checker(w-1, 0, 1, g.H, pixel.Accent)
}
})
res := ScrollAreaResult{Element: row, Changed: s.Y != *last}
*last = s.Y
if res.Changed {
slog.Debug("8bit: scrollarea scrolled", "y", s.Y)
}
return res
}
// scrollBar draws arrows, track and thumb, and moves s.
func scrollBar(c *ui.Context, k pixel.Kit, s *ui.ScrollState, view float32, disabled bool) {
clamp := func(y float32) { s.Y = max(0, min(y, s.MaxY)) }
ui.Column(c).Width(k.D(barW)).DrawOver(func(p *ui.Painter, r ui.Rect) {
if disabled {
g := k.Canvas(p, r)
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
}).Children(func() {
if arrow(c, k, upArrow, k.Msg("scroll_up"), disabled) {
clamp(s.Y - k.D(16))
}
track := ui.Box(c).Grow(1)
h := track.Bounds().H
size, at := thumb(h, view, s.Y, s.MaxY, k.Dot())
track.Draw(func(p *ui.Painter, r ui.Rect) {
if r.H != h {
c.Invalidate()
}
box(k.Canvas(p, r), pixel.Sunken, pixel.Well)
}).Children(func() {
th := ui.Box(c).Absolute().Top(at).Left(0).FillWidth().Height(size)
th.Clicked()
if _, dy, ok := th.Dragged(); ok && !disabled && dy != 0 && h > size {
clamp(s.Y + dy*s.MaxY/(h-size))
}
th.Draw(func(p *ui.Painter, r ui.Rect) { box(k.Canvas(p, r), pixel.Raised, pixel.Face) })
})
if track.Clicked() && !disabled {
if _, y, _ := track.PointerPosition(); y < at {
clamp(s.Y - view)
} else if y > at+size {
clamp(s.Y + view)
}
}
if arrow(c, k, downArrow, k.Msg("scroll_down"), disabled) {
clamp(s.Y + k.D(16))
}
})
}
// thumb sizes and places the thumb in track h.
func thumb(h, view, y, maxY, dot float32) (size, at float32) {
if maxY <= 0 || view <= 0 {
return h, 0
}
size = min(h, max(8*dot, h*view/(view+maxY)))
size = float32(int(size/dot)) * dot
return size, float32(int((h-size)*y/maxY/dot)) * dot
}
// arrow is a scrollbar button; keys scroll the view instead.
func arrow(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, label string, disabled bool) bool {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Role(ui.RoleButton).Label(label).Disabled(disabled)
})
shell.Size(k.D(barW), k.D(barW)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
style, y := pixel.Raised, 0
if st.Press {
style, y = pixel.Sunken, 1
}
box(g, style, pixel.Face)
g.Sprite((g.W-sp.W)/2, (g.H-sp.H)/2+y, sp, 0, pixel.Ink, 1)
})
return base.Clicked()
}
+30
View File
@@ -0,0 +1,30 @@
package layout
import (
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SectionOptions struct {
// Gap is between children; zero means 4.
Gap int
}
// Section titles a group inside a dotted rule.
func Section(c *ui.Context, title string, o SectionOptions, children func()) *ui.Element {
k := pixel.K(c)
up := strings.ToUpper(title)
return ui.Column(c).Gap(k.D(orDef("Section Gap", o.Gap, 4))).Role(ui.RoleGroup).Label(title).Children(func() {
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
mid, tw := pixel.LineHeight/2, pixel.Measure(up)
g.Checker(0, mid, 6, 1, pixel.Faint)
g.Text(10, 0, up, pixel.Ink, 1)
g.Checker(14+tw, mid, g.W-14-tw, 1, pixel.Faint)
})
children()
})
}
+117
View File
@@ -0,0 +1,117 @@
package layout
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
const gripW = 6
type SplitOptions struct {
// Vertical stacks the panes top and bottom.
Vertical bool
// Min is the smallest pane in dots; zero means 24.
Min int
Label string
Disabled bool
}
type SplitResult struct {
Element *ui.Element
Changed bool
}
// Split shows two panes and a grip moving *size dots.
func Split(c *ui.Context, size *int, o SplitOptions, first, second func()) SplitResult {
k := pixel.K(c)
least := orDef("Split Min", o.Min, 24)
var e *ui.Element
var total float32
back, fwd := ui.KeyLeft, ui.KeyRight
if o.Vertical {
e = ui.Column(c)
total, back, fwd = e.Bounds().H, ui.KeyUp, ui.KeyDown
} else {
e = ui.Row(c)
total = e.Bounds().W
}
limit := func(n int) int {
if total > 0 {
n = min(n, int(total/k.Dot())-gripW-least)
}
return max(n, least)
}
start := *size
e.AlignItems(ui.Stretch).Children(func() {
pane := ui.Box(c).Clip().Children(first)
shell, base, st := pixel.Shell(c, func() *ui.Element {
cur := ui.CursorResizeEW
if o.Vertical {
cur = ui.CursorResizeNS
}
return ui.Box(c).Focusable().Role(ui.RoleSplitter).Label(label(k, o.Label, "resize")).Disabled(o.Disabled).Cursor(cur)
})
if dx, dy, ok := drag(k, base); ok {
if o.Vertical {
dx = dy
}
*size += dx
}
if !o.Disabled && base.Shortcut(0, back) {
*size -= 4
}
if !o.Disabled && base.Shortcut(0, fwd) {
*size += 4
}
*size = limit(*size)
if o.Vertical {
pane.FillWidth().Height(k.D(*size))
shell.FillWidth().Height(k.D(gripW))
} else {
pane.FillHeight().Width(k.D(*size))
shell.FillHeight().Width(k.D(gripW))
}
shell.Draw(func(p *ui.Painter, r ui.Rect) { grip(k.Canvas(p, r), st, o.Vertical) })
ui.Box(c).Grow(1).Clip().Children(second)
})
e.Draw(func(p *ui.Painter, r ui.Rect) {
now := r.W
if o.Vertical {
now = r.H
}
if now != total {
c.Invalidate()
}
})
res := SplitResult{Element: e, Changed: *size != start}
if res.Changed {
slog.Debug("8bit: split moved", "size", *size)
}
return res
}
// grip is a bar with three bumps across its length.
func grip(g pixel.Canvas, st pixel.State, vertical bool) {
fill := pixel.Face
if st.Hover || st.Press {
fill = pixel.Lit
}
g.Rect(0, 0, g.W, g.H, fill)
for i := -1; i <= 1; i++ {
x, y := g.W/2-1, g.H/2-1+i*4
if vertical {
x, y = g.W/2-1+i*4, g.H/2-1
}
g.Dot(x, y, pixel.Lit)
g.Dot(x+1, y+1, pixel.Shade)
}
if st.Disabled {
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
}
+34
View File
@@ -0,0 +1,34 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// StatusBar shows segments split by sunken dividers; the first grows.
func StatusBar(c *ui.Context, segments ...string) *ui.Element {
k := pixel.K(c)
return ui.Row(c).FillWidth().Height(k.D(pixel.LineHeight + 3)).AlignItems(ui.Stretch).Role(ui.RoleStatus).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, 0, g.W, pixel.Edge)
g.HLine(0, 1, g.W, pixel.Lit)
}).Children(func() {
for i, s := range segments {
if i > 0 {
ui.Box(c).Width(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.VLine(0, 3, g.H-5, pixel.Shade)
g.VLine(1, 3, g.H-5, pixel.Lit)
})
}
seg := ui.Box(c).Padding(k.D(2), k.D(6), 0, k.D(6)).Children(func() {
pixel.Text(c, s, pixel.TextOptions{})
})
if i == 0 {
seg.Grow(1).Clip()
}
}
})
}
+27
View File
@@ -0,0 +1,27 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TilesOptions struct {
// W is zero for 48; H is zero for W.
W, H int
// Gap is zero for 4.
Gap int
}
// Tiles wraps n equal tiles into rows.
func Tiles(c *ui.Context, n int, o TilesOptions, tile func(i int)) *ui.Element {
k := pixel.K(c)
w := orDef("Tiles W", o.W, 48)
h := orDef("Tiles H", o.H, w)
orDef("Tiles n", n, 0)
return ui.Row(c).Wrap().Gap(k.D(orDef("Tiles Gap", o.Gap, 4))).Children(func() {
for i := range n {
ui.Box(c).Size(k.D(w), k.D(h)).Clip().Children(func() { tile(i) })
}
})
}
+89
View File
@@ -0,0 +1,89 @@
package layout
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var (
closeGlyph = pixel.MustSprite(`
#...#
.#.#.
..#..
.#.#.
#...#
`)
minimizeGlyph = pixel.MustSprite(`
.....
.....
.....
.....
#####
`)
)
type TitleBarOptions struct {
// NoMinimize leaves out the minimize box.
NoMinimize bool
Disabled bool
}
type TitleBarResult struct {
Element *ui.Element
Close bool
Minimize bool
}
// TitleBar is a striped bar: title, close box, minimize box.
func TitleBar(c *ui.Context, title string, o TitleBarOptions) TitleBarResult {
k := pixel.K(c)
tw := pixel.Measure(title)
res := TitleBarResult{}
res.Element = ui.Row(c).FillWidth().Height(k.D(pixel.LineHeight+2)).AlignItems(ui.Center).Padding(0, k.D(4)).Role(ui.RoleGroup).Label(title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, g.H-1, g.W, pixel.Edge)
for y := 4; y < g.H-4; y += 2 {
g.HLine(2, y, g.W-4, pixel.Shade)
}
x := (g.W - tw) / 2
g.Rect(x-6, 0, tw+12, g.H-1, pixel.Face)
g.Text(x, 1, title, pixel.Ink, 1)
}).Children(func() {
res.Close = windowBox(c, k, closeGlyph, k.Msg("close"), o.Disabled)
ui.Spacer(c)
if !o.NoMinimize {
res.Minimize = windowBox(c, k, minimizeGlyph, k.Msg("minimize"), o.Disabled)
}
})
if res.Close || res.Minimize {
slog.Debug("8bit: titlebar", "title", title, "close", res.Close, "minimize", res.Minimize)
}
return res
}
// windowBox is a framed button showing its glyph on hover.
func windowBox(c *ui.Context, k pixel.Kit, glyph *pixel.Sprite, name string, disabled bool) bool {
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name).Disabled(disabled) })
shell.Size(k.D(11), k.D(11)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
fill := pixel.Face
if st.Press {
fill = pixel.Shade
}
box(g, pixel.Flat, fill)
if st.Hover || st.Press || st.Focus {
g.Sprite(3, 3, glyph, 0, pixel.Ink, 1)
}
if st.Disabled {
g.Checker(0, 0, g.W, g.H, pixel.Face)
}
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
})
return base.Clicked()
}
+47
View File
@@ -0,0 +1,47 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ViewportOptions struct {
// W×H is the virtual screen in dots; zero means 160×144.
W, H int
}
// Viewport fills its parent and draws children at W×H dots.
func Viewport(c *ui.Context, o ViewportOptions, children func()) *ui.Element {
k := pixel.K(c)
vw, vh := orDef("Viewport W", o.W, 160), orDef("Viewport H", o.H, 144)
base := int(k.Dot())
outer := ui.Box(c).Fill()
was := outer.Bounds()
d := fit(vw, vh, was.W, was.H, base)
// Offsets stay whole outer dots, so both grids line up.
x := (int(was.W)/base - vw*d/base) / 2 * base
y := (int(was.H)/base - vh*d/base) / 2 * base
return outer.Draw(func(p *ui.Painter, r ui.Rect) {
if r.W != was.W || r.H != was.H {
c.Invalidate()
}
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Back)
}).Children(func() {
s := pixel.Current(c)
s.Dot = d
pixel.Scope(c, s, func() {
ui.Box(c).Absolute().Left(float32(max(0, x))).Top(float32(max(0, y))).Size(float32(vw*d), float32(vh*d)).Clip().Children(children)
})
})
}
// fit picks the largest fitting multiple of base, ≤ 4.
func fit(w, h int, roomW, roomH float32, base int) int {
d := base
for d+base <= 4 && float32(w*(d+base)) <= roomW && float32(h*(d+base)) <= roomH {
d += base
}
return d
}
+42
View File
@@ -0,0 +1,42 @@
package layout
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
const tabH = pixel.LineHeight + 2
type WindowOptions struct {
// Title shows in a tab above the body.
Title string
// Padding is inside the bevel; zero means 6.
Padding int
}
// Window is a beveled window with an optional title tab.
func Window(c *ui.Context, o WindowOptions, children func()) *ui.Element {
k := pixel.K(c)
pad := 3 + orDef("Window Padding", o.Padding, 6)
top := 0
e := ui.Column(c)
if o.Title != "" {
top = tabH - 1
e.Role(ui.RoleGroup).Label(o.Title)
}
return e.Padding(k.D(top+pad), k.D(pad), k.D(pad), k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if g.W < 12 || g.H < top+6 {
return
}
g.Frame(0, top, g.W, g.H-top, pixel.Window, pixel.Face)
if o.Title == "" {
return
}
w := min(pixel.Measure(o.Title)+12, g.W-8)
g.Frame(4, 0, w, tabH, pixel.Raised, pixel.Face)
g.Rect(5, tabH-2, w-2, 4, pixel.Face)
g.Clip(4, 0, w-2, tabH, func() { g.Text(10, 1, o.Title, pixel.Ink, 1) })
}).Children(children)
}