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
@@ -54,6 +54,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
|
||||
|
||||
- Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots.
|
||||
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`.
|
||||
- Containers take content as a final `func()`; interactive results are `NameResult`.
|
||||
- Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child.
|
||||
- Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`.
|
||||
- Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field.
|
||||
- Copy shown by a component comes from `k.Msg`; business copy comes from the caller.
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var layoutEntries = []Entry{
|
||||
{1, "Screen", "屏幕", "layout", "The root of a screen: Back fills the window, with an optional dithered vignette.", demoScreen},
|
||||
{2, "Window", "窗口", "layout", "A beveled window with a title tab, the shape of every RPG menu.", demoWindow},
|
||||
{3, "Panel", "面板", "layout", "A box framed raised, sunken, flat, double, window or notched.", demoPanel},
|
||||
{4, "Card", "卡片", "layout", "A raised panel with a header strip, a body and a footer.", demoCard},
|
||||
{5, "Section", "分节", "layout", "A titled group inside a dotted rule.", demoSection},
|
||||
{6, "Divider", "分隔线", "layout", "Rules across or down: solid, dotted, dashed, double, ornament.", demoDivider},
|
||||
{7, "ScrollArea", "滚动区", "layout", "A scroll view with a pixel scrollbar: arrows step, the track pages, the thumb drags.", demoScrollArea},
|
||||
{8, "Split", "分栏", "layout", "Two panes and a grip. Drag it, or focus it and use the arrow keys.", demoSplit},
|
||||
{9, "Grid", "网格", "layout", "Equal columns with solid or dotted lines between the cells.", demoGrid},
|
||||
{10, "Tiles", "平铺", "layout", "Equal tiles that wrap into rows as the width allows.", demoTiles},
|
||||
{11, "Masonry", "瀑布流", "layout", "Each item drops into the shortest column.", demoMasonry},
|
||||
{12, "AspectBox", "比例框", "layout", "Keeps a ratio in whole dots at whatever width it gets.", demoAspectBox},
|
||||
{13, "Layers", "图层", "layout", "Layers stacked in one box, each over the one before.", demoLayers},
|
||||
{14, "Viewport", "视口", "layout", "A fixed virtual resolution, drawn at the largest Dot that fits and letterboxed.", demoViewport},
|
||||
{15, "TitleBar", "标题栏", "layout", "Stripes, a title, a close box and a minimize box.", demoTitleBar},
|
||||
{16, "StatusBar", "状态栏", "layout", "Segments split by sunken dividers; the first one grows.", demoStatusBar},
|
||||
{17, "Resizable", "可调尺寸", "layout", "A box with a corner grip. Drag it, or focus it and use the arrow keys.", demoResizable},
|
||||
{18, "Handheld", "掌机", "layout", "A pocket console: a screen, a power LED, a D-pad, A and B.", demoHandheld},
|
||||
{19, "CRTFrame", "显像管", "layout", "A monitor bezel with scanlines over the picture.", demoCRTFrame},
|
||||
{20, "AppFrame", "应用框架", "layout", "Header, sidebar, main and footer; on a narrow screen the sidebar folds above.", demoAppFrame},
|
||||
}
|
||||
|
||||
var monsters = []string{"Slime", "Bat", "Goblin", "Mimic", "Ghost", "Golem", "Drake", "Imp"}
|
||||
|
||||
func demoScreen(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
on := ui.Local(c.Root(), demoKey("screen"), func() bool { return true })
|
||||
ui.Column(c).Gap(k.D(6)).Children(func() {
|
||||
ui.Box(c).FillWidth().Height(k.D(96)).Children(func() {
|
||||
layout.Screen(c, layout.ScreenOptions{Vignette: *on, Padding: 12}, func() {
|
||||
label(c, "A NEW SAVE FILE", pixel.Ink)
|
||||
label(c, "Day 1. The cartridge starts.", pixel.Faint)
|
||||
})
|
||||
})
|
||||
if button(c, "VIGNETTE") {
|
||||
*on = !*on
|
||||
log(fmt.Sprintf("vignette %v", *on))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoWindow(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Children(func() {
|
||||
layout.Window(c, layout.WindowOptions{Title: "ITEMS"}, func() {
|
||||
for _, s := range []string{"Potion x3", "Ether x1", "Key x2"} {
|
||||
label(c, s, pixel.Ink)
|
||||
}
|
||||
})
|
||||
layout.Window(c, layout.WindowOptions{}, func() {
|
||||
label(c, "HP 32/40", pixel.Ink)
|
||||
label(c, "MP 12/18", pixel.Ink)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoPanel(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Wrap().Gap(k.D(6)).Children(func() {
|
||||
for i, name := range []string{"RAISED", "SUNKEN", "FLAT", "DOUBLE", "WINDOW", "NOTCHED"} {
|
||||
layout.Panel(c, layout.PanelOptions{Style: pixel.FrameStyle(i)}, func() { label(c, name, pixel.Ink) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoCard(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).Width(k.D(200)).Children(func() {
|
||||
layout.Card(c, layout.CardOptions{Title: "QUEST", Icon: icon.Scroll, Footer: func() {
|
||||
if button(c, "ACCEPT") {
|
||||
log("quest accepted")
|
||||
}
|
||||
if button(c, "LATER") {
|
||||
log("quest saved for later")
|
||||
}
|
||||
}}, func() {
|
||||
label(c, "Find the lost cartridge.", pixel.Ink)
|
||||
label(c, "Reward: 300 G", pixel.Faint)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoSection(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).Width(k.D(220)).Children(func() {
|
||||
layout.Section(c, "Sound", layout.SectionOptions{}, func() {
|
||||
label(c, "Music 8", pixel.Ink)
|
||||
label(c, "Effects 5", pixel.Ink)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoDivider(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
names := []string{"SOLID", "DOTTED", "DASHED", "DOUBLE", "ORNAMENT"}
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Stretch).Children(func() {
|
||||
ui.Column(c).Grow(1).Gap(k.D(4)).Children(func() {
|
||||
for i, n := range names {
|
||||
label(c, n, pixel.Faint)
|
||||
layout.Divider(c, layout.DividerOptions{Style: layout.DividerStyle(i)})
|
||||
}
|
||||
})
|
||||
for i := range names {
|
||||
layout.Divider(c, layout.DividerOptions{Vertical: true, Style: layout.DividerStyle(i), Slot: pixel.Accent})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoScrollArea(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := ui.Local(c.Root(), demoKey("scroll"), func() ui.ScrollState { return ui.ScrollState{} })
|
||||
ui.Column(c).Gap(k.D(4)).Children(func() {
|
||||
r := layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: "Bestiary"}, func() {
|
||||
for i := range 40 {
|
||||
label(c, fmt.Sprintf("No.%03d %s", i+1, monsters[i%len(monsters)]), pixel.Ink)
|
||||
}
|
||||
})
|
||||
r.Element.Size(k.D(200), k.D(112))
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("scrolled to %d dots", int(s.Y/k.Dot())))
|
||||
}
|
||||
label(c, fmt.Sprintf("Y %d of %d dots", int(s.Y/k.Dot()), int(s.MaxY/k.Dot())), pixel.Faint)
|
||||
})
|
||||
}
|
||||
|
||||
func demoSplit(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
size := ui.Local(c.Root(), demoKey("split"), func() int { return 120 })
|
||||
pane := func(name string) func() {
|
||||
return func() {
|
||||
layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { label(c, name, pixel.Ink) }).Fill()
|
||||
}
|
||||
}
|
||||
ui.Box(c).FillWidth().Height(k.D(96)).Children(func() {
|
||||
r := layout.Split(c, size, layout.SplitOptions{Label: "Map and log"}, pane("MAP"), pane("LOG"))
|
||||
r.Element.Fill()
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("split %d dots", *size))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoGrid(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
items := []*pixel.Sprite{icon.Sword, icon.Shield, icon.Potion, icon.Gem, icon.Key, icon.Coin}
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Start).Children(func() {
|
||||
for _, lines := range []layout.GridLines{layout.SolidLines, layout.DottedLines} {
|
||||
layout.Grid(c, len(items), layout.GridOptions{Columns: 3, Lines: lines}, func(i int) {
|
||||
pixel.Icon(c, items[i], pixel.IconOptions{Scale: 2})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoTiles(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
items := []*pixel.Sprite{icon.Sun, icon.Moon, icon.Cloud, icon.Snowflake, icon.Leaf, icon.Flower, icon.Flame, icon.Drop, icon.Bolt, icon.Star}
|
||||
layout.Tiles(c, len(items), layout.TilesOptions{W: 28}, func(i int) {
|
||||
ui.Box(c).Fill().Center().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
k.Canvas(p, r).Frame(0, 0, 28, 28, pixel.Raised, pixel.Face)
|
||||
}).Children(func() { pixel.Icon(c, items[i], pixel.IconOptions{}) })
|
||||
}).Width(k.D(160))
|
||||
}
|
||||
|
||||
func demoMasonry(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
heights := []int{40, 24, 56, 32, 24, 48, 32, 40}
|
||||
slots := []pixel.Slot{pixel.Accent, pixel.Good, pixel.Info, pixel.Warn, pixel.Magic}
|
||||
layout.Masonry(c, heights, layout.MasonryOptions{}, func(i int) {
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Flat, slots[i%len(slots)])
|
||||
g.Micro(3, 3, fmt.Sprint(i+1), pixel.Ink, 1)
|
||||
})
|
||||
}).Width(k.D(180))
|
||||
}
|
||||
|
||||
func demoAspectBox(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).Width(k.D(160)).Children(func() {
|
||||
layout.AspectBox(c, layout.AspectBoxOptions{W: 16, H: 9}, func() {
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i := range 17 {
|
||||
g.Dither(0, i*g.H/17, g.W, g.H/17+1, pixel.Back, pixel.Info, i)
|
||||
}
|
||||
g.Text(4, 2, fmt.Sprintf("16:9 %d×%d", g.W, g.H), pixel.Ink, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoLayers(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
layout.Layers(c, func() {
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 6)
|
||||
})
|
||||
}, func() {
|
||||
ui.Box(c).Fill().Center().Children(func() { pixel.Icon(c, icon.Chest, pixel.IconOptions{Scale: 3}) })
|
||||
}, func() {
|
||||
ui.Box(c).Padding(k.D(3)).Children(func() { label(c, "LAYER 3", pixel.Ink) })
|
||||
}).Size(k.D(120), k.D(64))
|
||||
}
|
||||
|
||||
func demoViewport(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).FillWidth().Height(k.D(100)).Children(func() {
|
||||
layout.Viewport(c, layout.ViewportOptions{W: 64, H: 40}, func() {
|
||||
kv := pixel.K(c)
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := kv.Canvas(p, r)
|
||||
g.Rect(0, 0, g.W, g.H, pixel.Info)
|
||||
g.Rect(0, 30, g.W, 10, pixel.Good)
|
||||
g.Disc(50, 10, 5, pixel.Warn)
|
||||
g.Micro(3, 3, "64X40", pixel.Ink, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoTitleBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).Width(k.D(220)).Children(func() {
|
||||
r := layout.TitleBar(c, "Untitled", layout.TitleBarOptions{})
|
||||
if r.Close {
|
||||
log("close")
|
||||
}
|
||||
if r.Minimize {
|
||||
log("minimize")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoStatusBar(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).Width(k.D(260)).Children(func() {
|
||||
layout.StatusBar(c, "Ready", "Ln 1, Col 1", "UTF-8")
|
||||
})
|
||||
}
|
||||
|
||||
func demoResizable(c *ui.Context, log func(string)) {
|
||||
w := ui.Local(c.Root(), demoKey("resize-w"), func() int { return 120 })
|
||||
h := ui.Local(c.Root(), demoKey("resize-h"), func() int { return 56 })
|
||||
r := layout.Resizable(c, w, h, layout.ResizableOptions{MinW: 48, MinH: 32, MaxW: 240, MaxH: 120}, func() {
|
||||
layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() {
|
||||
label(c, fmt.Sprintf("%d×%d", *w, *h), pixel.Ink)
|
||||
}).Fill()
|
||||
})
|
||||
if r.Changed {
|
||||
log(fmt.Sprintf("size %d×%d", *w, *h))
|
||||
}
|
||||
}
|
||||
|
||||
func demoHandheld(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
at := ui.Local(c.Root(), demoKey("handheld"), func() [2]int { return [2]int{36, 20} })
|
||||
r := layout.Handheld(c, layout.HandheldOptions{ScreenW: 80, ScreenH: 56, Brand: "8BIT POCKET", Label: "Pocket console"}, func() {
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Rect(0, 0, g.W, g.H, pixel.Well)
|
||||
g.Sprite(at[0], at[1], slime, g.Step(2), pixel.Ink, 1)
|
||||
})
|
||||
})
|
||||
moves := map[string][2]int{"up": {0, -4}, "down": {0, 4}, "left": {-4, 0}, "right": {4, 0}}
|
||||
for name, on := range map[string]bool{"up": r.Up, "down": r.Down, "left": r.Left, "right": r.Right} {
|
||||
if on {
|
||||
at[0] = max(0, min(at[0]+moves[name][0], 80-slime.W))
|
||||
at[1] = max(0, min(at[1]+moves[name][1], 56-slime.H))
|
||||
log(name)
|
||||
}
|
||||
}
|
||||
if r.A {
|
||||
log("A: jump")
|
||||
}
|
||||
if r.B {
|
||||
log("B: run")
|
||||
}
|
||||
}
|
||||
|
||||
func demoCRTFrame(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
layout.CRTFrame(c, layout.CRTFrameOptions{Brand: "8BIT CRT"}, func() {
|
||||
ui.Column(c).Size(k.D(180), k.D(72)).Padding(k.D(4)).Children(func() {
|
||||
label(c, "READY.", pixel.Good)
|
||||
label(c, "10 PRINT \"HI\"", pixel.Good)
|
||||
})
|
||||
}).AlignSelf(ui.Start)
|
||||
}
|
||||
|
||||
func demoAppFrame(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Box(c).FillWidth().Height(k.D(150)).Children(func() {
|
||||
layout.AppFrame(c, layout.AppFrameOptions{SidebarWidth: 72}, func() {
|
||||
label(c, "8BIT MAIL", pixel.Ink)
|
||||
}, func() {
|
||||
for _, s := range []string{"Inbox", "Sent", "Spam"} {
|
||||
if button(c, s) {
|
||||
log("open " + s)
|
||||
}
|
||||
}
|
||||
}, func() {
|
||||
label(c, "No new mail. Go outside.", pixel.Ink)
|
||||
}, func() {
|
||||
label(c, "3 folders", pixel.Faint)
|
||||
})
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ var Categories = []Category{
|
||||
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
||||
}
|
||||
|
||||
var entries = slices.Concat(foundationEntries)
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries)
|
||||
|
||||
func Entries() []Entry { return entries }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+6
-2
@@ -6,12 +6,16 @@ var messages = map[Locale]map[string]string{
|
||||
En: {
|
||||
"ok": "OK", "cancel": "CANCEL", "yes": "YES", "no": "NO", "close": "CLOSE",
|
||||
"continue": "CONTINUE?", "game_over": "GAME OVER", "loading": "NOW LOADING",
|
||||
"search": "SEARCH", "empty": "NO ITEMS",
|
||||
"search": "SEARCH", "empty": "NO ITEMS", "minimize": "MINIMIZE", "resize": "RESIZE",
|
||||
"scroll_up": "SCROLL UP", "scroll_down": "SCROLL DOWN",
|
||||
"up": "UP", "down": "DOWN", "left": "LEFT", "right": "RIGHT", "disabled": "DISABLED",
|
||||
},
|
||||
Zh: {
|
||||
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
||||
"continue": "继续?", "game_over": "游戏结束", "loading": "读取中",
|
||||
"search": "搜索", "empty": "空空如也",
|
||||
"search": "搜索", "empty": "空空如也", "minimize": "最小化", "resize": "调整大小",
|
||||
"scroll_up": "向上滚动", "scroll_down": "向下滚动",
|
||||
"up": "上", "down": "下", "left": "左", "right": "右", "disabled": "已禁用",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ func Use(c *ui.Context, s Settings) {
|
||||
// Typing reports a focused Editor built so far this frame.
|
||||
func Typing(c *ui.Context) bool { return current(c).typing }
|
||||
|
||||
// Current returns the settings in force, for Scope to change.
|
||||
func Current(c *ui.Context) Settings { return current(c).cur }
|
||||
|
||||
// Scope builds fn under other settings.
|
||||
func Scope(c *ui.Context, s Settings, fn func()) {
|
||||
f := current(c)
|
||||
|
||||
@@ -13,6 +13,9 @@ async function open (page, hash = '') {
|
||||
const texts = page => page.evaluate(() => window.__8bit.texts())
|
||||
const where = page => page.evaluate(() => window.__8bit.page())
|
||||
|
||||
// searched reports whether the page shows results for q.
|
||||
const searched = async (page, q) => (await texts(page)).some(t => t.endsWith(`results for "${q}"`))
|
||||
|
||||
// pixels hashes the canvas, to see that it changed.
|
||||
const pixels = page => page.evaluate(() => {
|
||||
const c = document.getElementById('screen')
|
||||
@@ -67,7 +70,7 @@ test('slash focuses search without typing a slash', async ({ page }) => {
|
||||
await open(page, '#/')
|
||||
await page.keyboard.press('/')
|
||||
await page.keyboard.type('dith')
|
||||
expect(await texts(page)).toContain('1 results for "dith"')
|
||||
expect(await searched(page, 'dith')).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
expect(await where(page)).toBe('/f/dither')
|
||||
expect(page.url()).toContain('#/f/dither')
|
||||
@@ -77,7 +80,7 @@ test('Chinese text reaches search', async ({ page }) => {
|
||||
await open(page, '#/')
|
||||
await page.keyboard.press('/')
|
||||
await page.keyboard.insertText('像素')
|
||||
expect(await texts(page)).toContain('1 results for "像素"')
|
||||
expect(await searched(page, '像素')).toBe(true)
|
||||
})
|
||||
|
||||
test('clicks navigate, Back returns, P repaints', async ({ page }) => {
|
||||
@@ -112,7 +115,7 @@ test('keyboard copy and paste use the system clipboard', async ({ page, context
|
||||
await page.evaluate(() => navigator.clipboard.writeText('dith'))
|
||||
await click(page, 'Search components')
|
||||
await page.keyboard.press('ControlOrMeta+V')
|
||||
expect(await texts(page)).toContain('1 results for "dith"')
|
||||
expect(await searched(page, 'dith')).toBe(true)
|
||||
})
|
||||
|
||||
test('an input method composes, then commits', async ({ page }) => {
|
||||
@@ -120,9 +123,9 @@ test('an input method composes, then commits', async ({ page }) => {
|
||||
await page.keyboard.press('/')
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Input.imeSetComposition', { text: 'xiang', selectionStart: 5, selectionEnd: 5 })
|
||||
expect(await texts(page)).not.toContain('1 results for "xiang"')
|
||||
expect(await searched(page, 'xiang')).toBe(false)
|
||||
await cdp.send('Input.insertText', { text: '像' })
|
||||
expect(await texts(page)).toContain('1 results for "像"')
|
||||
expect(await searched(page, '像')).toBe(true)
|
||||
})
|
||||
|
||||
test('Command or Super with Backspace erases the line', async ({ page }) => {
|
||||
@@ -131,7 +134,7 @@ test('Command or Super with Backspace erases the line', async ({ page }) => {
|
||||
await page.keyboard.type('zz dith')
|
||||
await page.keyboard.press('Meta+Backspace')
|
||||
await page.keyboard.type('dith')
|
||||
expect(await texts(page)).toContain('1 results for "dith"')
|
||||
expect(await searched(page, 'dith')).toBe(true)
|
||||
})
|
||||
|
||||
test('wheel scrolls the sidebar', async ({ page }) => {
|
||||
|
||||
Reference in new issue
Block a user