Add layout components 001-020 with demos and behavior tests
This commit is contained in:
29 files changed
+2110
-9
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user