Add gallery app, native window and architecture tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 16:45:34 -04:00
1 parent 7e47e3a924
commit d3b4adebb0
16 files changed
+1552 -5

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
package gallery
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// App is the gallery that native and web share.
type App struct {
router *ui.Router
palette int
locale pixel.Locale
started bool
query string
search pixel.Edit
logs map[string][]string
}
// New starts at path; the boot screen shows first.
func New(path string) *App {
r := ui.NewRouter(path)
r.Transition = ui.TransitionNone
return &App{router: r, logs: map[string][]string{}}
}
// Path is the page shown, for the web address bar.
func (a *App) Path() string { return a.router.Location() }
// Go opens path, skipping boot and clearing search.
func (a *App) Go(path string) {
a.started, a.query = true, ""
if path != a.router.Location() {
slog.Debug("8bit: gallery go", "path", path)
a.router.Push(path)
}
}
func (a *App) View(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.Palettes[a.palette], Locale: a.locale})
if !a.started {
a.boot(c)
return
}
ui.Column(c).Fill().Children(func() {
a.topBar(c)
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
a.sidebar(c)
ui.Column(c).Grow(1).Children(func() { a.content(c) })
})
a.statusBar(c)
})
a.keys(c)
}
// keys leaves single keys to a field being typed in.
func (a *App) keys(c *ui.Context) {
if a.search.Focused && c.Shortcut(0, ui.KeyEscape) {
a.query = ""
}
if pixel.Typing(c) {
return
}
switch {
case c.Shortcut(0, ui.KeySlash):
a.search.Input.Focus()
case c.Shortcut(0, ui.KeyP):
a.setPalette(a.palette + 1)
case c.Shortcut(ui.Shift, ui.KeyP):
a.setPalette(a.palette - 1)
case c.Shortcut(0, ui.KeyBracketLeft):
a.step(-1)
case c.Shortcut(0, ui.KeyBracketRight):
a.step(1)
}
}
func (a *App) setPalette(i int) {
n := len(pixel.Palettes)
a.palette = (i%n + n) % n
slog.Debug("8bit: gallery palette", "name", pixel.Palettes[a.palette].Name)
}
// step moves to the previous or next entry.
func (a *App) step(d int) {
all := Entries()
for i, e := range all {
if e.Path() == a.router.Path() {
a.Go(all[((i+d)%len(all)+len(all))%len(all)].Path())
return
}
}
}
// logger records a demo's events for the log panel.
func (a *App) logger(e Entry) func(string) {
return func(ev string) {
slog.Debug("8bit: demo", "entry", e.Slug(), "event", ev)
l := append(a.logs[e.Slug()], ev)
a.logs[e.Slug()] = l[max(0, len(l)-8):]
}
}
func find(slug string) (Entry, bool) {
for _, e := range Entries() {
if e.Slug() == slug {
return e, true
}
}
return Entry{}, false
}
func matches(q string) []Entry {
q = strings.ToLower(strings.TrimSpace(q))
var out []Entry
for _, e := range Entries() {
if strings.Contains(strings.ToLower(e.Name+" "+e.Zh+" "+e.Slug()), q) {
out = append(out, e)
}
}
return out
}
+169
View File
@@ -0,0 +1,169 @@
package gallery
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func open(path string) (*App, *ui.Tester) {
a := New("/")
if path != "" {
a.Go(path)
}
return a, ui.NewTester(a.View, 1280, 800)
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func TestBootThenStart(t *testing.T) {
a, tt := open("")
if a.started || !tt.HasText("Press start") {
t.Fatal("gallery did not open on the boot screen")
}
tt.Key(0, ui.KeyEnter)
if !a.started || !tt.HasText("Foundations") {
t.Fatal("Enter did not start the gallery")
}
}
func TestNavigation(t *testing.T) {
a, tt := open("/")
click(t, tt, "Foundations")
if a.Path() != "/cat/foundations" {
t.Fatalf("path %q after choosing a category", a.Path())
}
click(t, tt, "Palettes")
if a.Path() != "/f/palettes" {
t.Fatalf("path %q after choosing an entry", a.Path())
}
tt.Key(0, ui.KeyBracketRight)
if a.Path() != "/f/canvas" {
t.Fatalf("] went to %q", a.Path())
}
tt.Key(0, ui.KeyBracketLeft)
tt.Key(0, ui.KeyBracketLeft)
if a.Path() != Entries()[len(Entries())-1].Path() {
t.Fatalf("[ from the first entry went to %q", a.Path())
}
click(t, tt, "home")
if a.Path() != "/" {
t.Fatalf("logo went to %q", a.Path())
}
}
func TestPaletteKeysAndButtons(t *testing.T) {
a, tt := open("/")
tt.Key(0, ui.KeyP)
if a.palette != 1 {
t.Fatalf("P chose palette %d", a.palette)
}
tt.Key(ui.Shift, ui.KeyP)
tt.Key(ui.Shift, ui.KeyP)
if a.palette != len(pixel.Palettes)-1 {
t.Fatalf("Shift+P twice chose palette %d", a.palette)
}
click(t, tt, "next palette")
if a.palette != 0 {
t.Fatalf("next palette chose %d", a.palette)
}
}
func TestSearch(t *testing.T) {
a, tt := open("/")
tt.Key(0, ui.KeySlash)
if !tt.Focused("Search components") {
t.Fatal("/ did not focus search")
}
tt.Type("dith")
if !tt.HasText("1 results") || !tt.HasText("Dither") {
t.Fatalf("results: %q", tt.Texts())
}
tt.Key(0, ui.KeyP)
if a.palette != 0 {
t.Fatal("P changed the palette while typing")
}
tt.Key(0, ui.KeyEnter)
if a.Path() != "/f/dither" || a.query != "" {
t.Fatalf("Enter left path %q query %q", a.Path(), a.query)
}
tt.Key(0, ui.KeySlash)
tt.Type("zz")
tt.Key(0, ui.KeyEscape)
if a.query != "" {
t.Fatalf("Escape left query %q", a.query)
}
}
func TestDemoLogAndCopy(t *testing.T) {
a, tt := open("/f/icons")
click(t, tt, "heart")
if !slices.Contains(a.logs["icons"], "icon heart") {
t.Fatalf("log %q", a.logs["icons"])
}
click(t, tt, "COPY CODE")
e, _ := find("icons")
if tt.Clipboard() != strings.ReplaceAll(e.Code(), "\t", " ") {
t.Fatal("COPY CODE copied something else")
}
}
func TestLostPageAndLocale(t *testing.T) {
a, tt := open("/nowhere")
if !tt.HasText("No page at /nowhere") {
t.Fatalf("lost page texts %q", tt.Texts())
}
click(t, tt, "language")
if a.locale != pixel.Zh || !tt.HasText("继续?") {
t.Fatal("language toggle did not switch built-in copy")
}
click(t, tt, "继续?")
if a.Path() != "/" {
t.Fatalf("continue went to %q", a.Path())
}
}
func TestGoSkipsBootAndSearch(t *testing.T) {
a := New("/")
a.query = "dith"
a.Go("/f/icons")
if !a.started || a.Path() != "/f/icons" || a.query != "" {
t.Fatalf("Go left started %v path %q query %q", a.started, a.Path(), a.query)
}
}
func TestSearchResultClearsQuery(t *testing.T) {
a, tt := open("/")
tt.Key(0, ui.KeySlash)
tt.Type("dith")
click(t, tt, "Dither")
if a.query != "" || a.Path() != "/f/dither" || !tt.HasText("COPY CODE") {
t.Fatalf("query %q path %q", a.query, a.Path())
}
}
func TestPathKindMustMatch(t *testing.T) {
_, tt := open("/c/palettes")
if !tt.HasText("No page at /c/palettes") {
t.Fatal("a foundation opened under /c/")
}
}
func TestShortcutsYieldToFields(t *testing.T) {
a, tt := open("/f/editor")
click(t, tt, "Name")
tt.TypeKey(0, ui.KeyP, "p")
tt.TypeKey(0, ui.KeyBracketRight, "]")
if a.palette != 0 || a.Path() != "/f/editor" {
t.Fatalf("typing changed palette %d or path %q", a.palette, a.Path())
}
}
+43
View File
@@ -0,0 +1,43 @@
package gallery
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type bootKey struct{}
// boot assembles the logo dot by dot, then waits.
func (a *App) boot(c *ui.Context) {
k := pixel.K(c)
step := k.Tick(c, 12)
first := ui.Local(c.Root(), bootKey{}, func() int { return step })
shown := min(16, step-*first)
if k.Reduce() {
shown = 16
}
start := shown == 16 && (k.Reduce() || step%12 < 8)
shell, base, _ := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label("Press start") })
shell.Fill().Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
w := pixel.Measure(logo) * 4
x, y := (g.W-w)/2, g.H/2-40
shadowText(g, x, y, logo, pixel.Accent, 4)
g.Veil(x, y, w+4, 68, pixel.Back, 16-shown)
if start {
s := "PRESS START"
g.Text((g.W-pixel.Measure(s)*2)/2, y+84, s, pixel.Ink, 2)
}
hint := "Enter · Space · Click 回车 · 空格 · 点击"
g.Text((g.W-pixel.Measure(hint))/2, g.H-28, hint, pixel.Faint, 1)
})
base.AutoFocus()
if base.Clicked() || c.Shortcut(0, ui.KeyEnter) || c.Shortcut(0, ui.KeySpace) {
slog.Debug("8bit: gallery start")
a.started = true
c.Invalidate()
}
}
+137
View File
@@ -0,0 +1,137 @@
package gallery
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
const logo = "8BIT·MYGO·UI"
func (a *App) topBar(c *ui.Context) {
k := pixel.K(c)
ui.Row(c).Height(k.D(24)).Padding(0, k.D(6)).Gap(k.D(8)).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-2, g.W, pixel.Shade)
g.HLine(0, g.H-1, g.W, pixel.Edge)
}).Children(func() {
if press(c, pixel.Measure(logo)+2, 18, "home", func(g pixel.Canvas, st pixel.State) {
ink := pixel.Accent
if st.Hover {
ink = pixel.AccentLit
}
shadowText(g, 0, 1, logo, ink, 1)
}) {
a.Go("/")
}
ui.Column(c).Width(k.D(150)).Padding(k.D(2), k.D(4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
}).Children(func() {
a.search = pixel.Editor(c, &a.query, pixel.EditorOptions{Placeholder: "/ search 搜索", Label: "Search components"})
if m := matches(a.query); a.search.Submitted && len(m) > 0 {
a.Go(m[0].Path())
}
})
ui.Spacer(c)
a.paletteControl(c)
other, mark := pixel.Zh, "中"
if a.locale == pixel.Zh {
other, mark = pixel.En, "EN"
}
if press(c, 20, 16, "language", func(g pixel.Canvas, st pixel.State) {
g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Face)
g.Text((g.W-pixel.Measure(mark))/2, 0, mark, pixel.Ink, 1)
}) {
a.locale = other
}
})
}
func (a *App) paletteControl(c *ui.Context) {
pal := pixel.Palettes[a.palette]
if press(c, 12, 16, "previous palette", func(g pixel.Canvas, st pixel.State) {
g.Sprite(0, 2, icon.ChevronLeft, 0, pixel.Ink, 1)
}) {
a.setPalette(a.palette - 1)
}
name := strings.ToUpper(pal.Name) + " " + pal.Zh
press(c, 4*pixel.Slots+6+pixel.Measure(name), 16, "palette "+pal.Name, func(g pixel.Canvas, st pixel.State) {
g.Rect(0, 2, 4*pixel.Slots+2, 12, pixel.Edge)
for i := range pixel.Slots {
g.Rect(1+i*4, 3, 4, 10, pixel.Slot(i))
}
g.Text(4*pixel.Slots+6, 0, name, pixel.Ink, 1)
})
if press(c, 12, 16, "next palette", func(g pixel.Canvas, st pixel.State) {
g.Sprite(0, 2, icon.ChevronRight, 0, pixel.Ink, 1)
}) {
a.setPalette(a.palette + 1)
}
}
func (a *App) sidebar(c *ui.Context) {
k := pixel.K(c)
here := a.router.Path()
ui.Scroll(c).Width(k.D(120)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.VLine(g.W-2, 0, g.H, pixel.Shade)
g.VLine(g.W-1, 0, g.H, pixel.Edge)
}).Children(func() {
ui.Column(c).Padding(k.D(4)).Gap(k.D(1)).Children(func() {
for _, cat := range Categories {
n := count(cat.Key)
path := "/cat/" + cat.Key
on := here == path
if press(c, 0, 16, cat.Name, func(g pixel.Canvas, st pixel.State) {
ink := pixel.Ink
if on {
g.Rect(0, 0, g.W, g.H, pixel.Accent)
ink = pixel.AccentInk
} else if st.Hover {
g.Rect(0, 0, g.W, g.H, pixel.Lit)
}
if n == 0 {
ink = pixel.Faint
}
g.Sprite(2, 2, cat.Icon, 0, ink, 1)
g.Text(17, 0, cat.Name, ink, 1)
num := fmt.Sprint(n)
g.Micro(g.W-pixel.MicroWidth(num)-3, 6, num, ink, 1)
}) {
a.Go(path)
}
}
})
})
}
func (a *App) statusBar(c *ui.Context) {
k := pixel.K(c)
hints := "/ search · P palette · [ ] prev next"
total := fmt.Sprintf("%d ENTRIES", len(Entries()))
ui.Row(c).Height(k.D(18)).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)
g.Text(6, 2, hints, pixel.Faint, 1)
g.Micro(g.W-pixel.MicroWidth(total)-6, 8, total, pixel.Faint, 1)
})
}
func count(cat string) int {
n := 0
for _, e := range Entries() {
if e.Category == cat {
n++
}
}
return n
}
+204
View File
@@ -0,0 +1,204 @@
package gallery
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var foundationEntries = []Entry{
{0, "Palettes", "调色板", "foundations", "Eight palettes of sixteen slots: four seasons, by day and by night. Components name slots; rotating the palette repaints everything.", demoPalettes},
{0, "Canvas", "画布", "foundations", "Rectangles, Bresenham lines, midpoint circles and six frame styles, all snapped to the dot grid.", demoCanvas},
{0, "Dither", "抖动", "foundations", "Ordered Bayer dither stands in for gradients, shadows and translucency: seventeen levels between two slots.", demoDither},
{0, "Pixel Font", "像素字体", "foundations", "Ark Pixel 12px with CJK, at integer scales, plus a hand-placed 3×5 micro font.", demoFont},
{0, "Icons", "图标", "foundations", "One hundred and nine icons placed dot by dot. Click one to name it.", demoIcons},
{0, "Sprites", "精灵", "foundations", "Sprites are string art in slot letters, with frames stepped by the clock. Click to turn around.", demoSprites},
{0, "Editor", "编辑器", "foundations", "One text editing core drawn in the pixel font: caret, selection, clipboard, undo and input methods.", demoEditor},
{0, "Focus", "焦点", "foundations", "Tab to see the focus ring and the pointing hand. Disabled controls dither out.", demoFocus},
}
func demoPalettes(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Grid(c).Columns(2).Gap(k.D(6)).Children(func() {
for _, pal := range pixel.Palettes {
pixel.Scope(c, pixel.Settings{Palette: pal}, func() {
if press(c, 0, 52, pal.Name, func(g pixel.Canvas, st pixel.State) {
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
g.Text(6, 4, pal.Name+" "+pal.Zh, pixel.Ink, 1)
g.Rect(5, 23, pixel.Slots*5+2, 10, pixel.Edge)
for i := range pixel.Slots {
g.Rect(6+i*5, 24, 5, 8, pixel.Slot(i))
}
g.Sprite(g.W-46, 4, icon.Heart, 0, pixel.Bad, 1)
g.Sprite(g.W-32, 4, icon.Star, 0, pixel.Warn, 1)
g.Sprite(g.W-18, 4, icon.Coin, 0, pixel.Ink, 1)
g.Text(6, 34, "HP 99 · 你好", pixel.Accent, 1)
}) {
log("palette " + pal.Name)
}
})
}
})
}
func demoCanvas(c *ui.Context, log func(string)) {
k := pixel.K(c)
styles := []string{"RAISED", "SUNKEN", "FLAT", "DOUBLE", "WINDOW", "NOTCHED"}
ui.Box(c).Height(k.D(132)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, name := range styles {
x := (i % 3) * 64
y := (i / 3) * 34
g.Frame(x, y, 58, 28, pixel.FrameStyle(i), pixel.Face)
g.Micro(x+(58-pixel.MicroWidth(name))/2, y+12, name, pixel.Ink, 1)
}
for i := range 8 {
g.Line(220, 100, 220+(i-4)*12, 72, pixel.Slot(8+i))
}
g.Disc(30, 104, 20, pixel.Info)
g.Circle(30, 104, 24, pixel.Accent)
g.Disc(90, 104, 12, pixel.Warn)
g.Circle(90, 104, 16, pixel.Bad)
g.Bevel(120, 84, 40, 40, pixel.Lit, pixel.Shade)
})
}
func demoDither(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Box(c).Height(k.D(118)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for level := range 17 {
g.Dither(level*12, 0, 12, 24, pixel.Face, pixel.Accent, level)
g.Micro(level*12+2, 27, fmt.Sprint(level), pixel.Faint, 1)
}
for band := range 8 {
g.Dither(0, 40+band*6, 204, 6, pixel.Info, pixel.Back, band*2)
}
g.Sprite(220, 44, icon.Chest, 0, pixel.Ink, 3)
g.Checker(220, 44, 36, 36, pixel.Face)
g.Micro(220, 84, "VEIL", pixel.Faint, 1)
})
}
func demoFont(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
pixel.Text(c, "The quick brown fox jumps over the lazy dog.", pixel.TextOptions{})
pixel.Text(c, "在限制中创造丰富。每个点都经过认真摆放。", pixel.TextOptions{})
pixel.Text(c, "LEVEL 99", pixel.TextOptions{Scale: 2, Slot: pixel.Accent})
pixel.Text(c, "像素", pixel.TextOptions{Scale: 3, Slot: pixel.Magic})
ui.Box(c).Height(k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Micro(0, 0, "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ +-.:/%!", pixel.Ink, 1)
})
pixel.Text(c, "Long text wraps at the width the layout gives it, and Chinese 中文也会按字断行,标点不会出现在行首。", pixel.TextOptions{Wrap: true, Slot: pixel.Faint})
})
}
func demoIcons(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Wrap().Gap(k.D(2)).Children(func() {
for _, n := range icon.All {
if press(c, 16, 16, n.Name, func(g pixel.Canvas, st pixel.State) {
if st.Hover {
g.Rect(0, 0, 16, 16, pixel.Lit)
}
g.Sprite(2, 2, n.Sprite, 0, pixel.Ink, 1)
}) {
log("icon " + n.Name)
}
}
})
}
var slime = pixel.MustSprite(`
................
................
......eeee......
....eeggggee....
...egllgggggge..
..egllgggggggge.
..eglggkggkggge.
..egggggkkgggge.
..eggggggggggse.
..egggggggggsse.
...essssssssse..
....eeeeeeeee...
................
................
................
................
.....eeeeee.....
...eegllggggee..
..egllggkggkgge.
.eglggggkkgggsge
.egggggggggggsse
.eggggggggggssse
..esssssssssssse
...eeeeeeeeeeee.
`)
var slimeLeft = slime.Mirror()
type demoKey string
func demoSprites(c *ui.Context, log func(string)) {
left := ui.Local(c.Root(), demoKey("sprites"), func() bool { return false })
sp := slime
if *left {
sp = slimeLeft
}
if press(c, 120, 48, "slime", func(g pixel.Canvas, st pixel.State) {
g.Rect(0, 40, g.W, 8, pixel.Good)
g.Dither(0, 40, g.W, 8, pixel.Good, pixel.Shade, 4)
step := g.Step(4)
g.Sprite(8+step%8*2, 16, sp, step/2, pixel.Ink, 2)
}) {
*left = !*left
log(fmt.Sprintf("turned, facing left %v", *left))
}
}
type editorDemo struct{ name, secret, notes string }
func demoEditor(c *ui.Context, log func(string)) {
k := pixel.K(c)
st := ui.Local(c.Root(), demoKey("editor"), func() editorDemo { return editorDemo{notes: "Two lines\nof notes"} })
field := func(v *string, o pixel.EditorOptions) {
panel(c, pixel.Sunken, pixel.Well, 3).Children(func() {
e := pixel.Editor(c, v, o)
if e.Changed {
log(o.Label + " changed")
}
if e.Submitted {
log(o.Label + " submitted")
}
})
}
ui.Column(c).Gap(k.D(6)).Children(func() {
field(&st.name, pixel.EditorOptions{Label: "Name", Placeholder: "Your name 你的名字"})
field(&st.secret, pixel.EditorOptions{Label: "Password", Placeholder: "Password", Mask: '★'})
field(&st.notes, pixel.EditorOptions{Label: "Notes", Multiline: true})
})
}
func demoFocus(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(16)).Padding(0, k.D(14)).Children(func() {
for _, name := range []string{"ATTACK", "MAGIC", "ITEM"} {
if button(c, name) {
log(name)
}
}
shell, _, _ := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label("RUN").Disabled(true) })
shell.Size(k.D(pixel.Measure("RUN")+12), k.D(16)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H-1, pixel.Raised, pixel.Face)
g.Text(6, 0, "RUN", pixel.Faint, 1)
g.Checker(0, 0, g.W, g.H-1, pixel.Face)
})
})
}
+106
View File
@@ -0,0 +1,106 @@
package gallery
import (
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// render draws view in a w×h dot window.
func render(view func(c *ui.Context), w, h int) *image.RGBA {
return ui.Render(view, 2*w, 2*h, 1)
}
func colors(ps ...*pixel.Palette) map[color.RGBA]bool {
out := map[color.RGBA]bool{}
for _, p := range ps {
for _, c := range p.Colors {
out[color.RGBA{c.R, c.G, c.B, 255}] = true
}
}
return out
}
// keepsStyle fails on off-palette colors or off-grid dots.
func keepsStyle(t *testing.T, name string, img *image.RGBA, allowed map[color.RGBA]bool) {
t.Helper()
b := img.Bounds()
for y := 0; y < b.Dy(); y += 2 {
for x := 0; x < b.Dx(); x += 2 {
c := img.RGBAAt(x, y)
if !allowed[c] {
t.Fatalf("%s: pixel %d,%d is %v, off the palette", name, x, y, c)
}
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
t.Fatalf("%s: dot at %d,%d is split across the grid", name, x/2, y/2)
}
}
}
}
func shot(t *testing.T, name string, img *image.RGBA) {
dir := os.Getenv("PIXEL_SHOTS")
if dir == "" {
return
}
f, err := os.Create(filepath.Join(dir, name+".png"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
func TestInvariantsEntries(t *testing.T) {
for _, e := range Entries() {
for i, pal := range pixel.Palettes {
allowed := colors(pal)
if strings.Contains(e.Code(), "pixel.Scope(") {
allowed = colors(pixel.Palettes...)
}
img := render(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pal})
ui.Column(c).Fill().Padding(pixel.K(c).D(8)).Children(func() { e.Demo(c, func(string) {}) })
}, 320, 240)
keepsStyle(t, e.Slug()+" in "+pal.Name, img, allowed)
if i == 0 {
shot(t, e.Slug(), img)
}
}
}
}
func TestInvariantsPages(t *testing.T) {
pages := map[string]string{"boot": "", "home": "/", "category": "/cat/foundations", "entry": "/f/palettes", "lost": "/nowhere", "search": "?pal"}
for name, path := range pages {
for i, pal := range pixel.Palettes {
a := New("/")
a.palette = i
switch {
case strings.HasPrefix(path, "?"):
a.started, a.query = true, path[1:]
case path != "":
a.Go(path)
}
allowed := colors(pal)
if name == "entry" {
allowed = colors(pixel.Palettes...)
}
img := render(a.View, 640, 400)
keepsStyle(t, name+" in "+pal.Name, img, allowed)
if i == 0 {
shot(t, "page-"+name, img)
}
}
}
}
+205
View File
@@ -0,0 +1,205 @@
package gallery
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func (a *App) content(c *ui.Context) {
if strings.TrimSpace(a.query) != "" {
page(c, func() { a.results(c) })
return
}
a.router.View(c, func(r *ui.Route) {
page(c, func() {
switch {
case r.Match("/"):
r.Title("8bitMyGoUI")
a.home(c)
case r.Match("/cat/{key}"):
a.category(c, r.Param("key"))
case r.Match("/c/{slug}"), r.Match("/f/{slug}"):
if e, ok := find(r.Param("slug")); ok && e.Path() == r.Path() {
r.Title(e.Name)
a.entry(c, e)
return
}
a.lost(c, r.Path())
default:
a.lost(c, r.Path())
}
})
}).Grow(1)
}
// page scrolls a page; a router's column would squeeze it.
func page(c *ui.Context, fn func()) {
k := pixel.K(c)
ui.Scroll(c).Grow(1).Padding(k.D(8)).Gap(k.D(6)).Children(fn)
}
func (a *App) home(c *ui.Context) {
k := pixel.K(c)
ui.Box(c).Height(k.D(52)).Draw(func(p *ui.Painter, r ui.Rect) {
shadowText(k.Canvas(p, r), 0, 0, logo, pixel.Accent, 3)
})
pixel.Text(c, "Pixel components for MyGo. Every dot sits on the grid; every color comes from sixteen.", pixel.TextOptions{Wrap: true})
pixel.Text(c, "为 MyGo 打造的像素组件。每个点落在点阵上,每种颜色来自十六格调色板。", pixel.TextOptions{Wrap: true, Slot: pixel.Faint})
comps := len(Entries()) - count("foundations")
stats := fmt.Sprintf("%d COMPONENTS %d CATEGORIES %d PALETTES", comps, len(Categories)-1, len(pixel.Palettes))
ui.Box(c).Height(k.D(7)).Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Micro(0, 1, stats, pixel.Accent, 1) })
ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() {
for _, cat := range Categories {
n := count(cat.Key)
if press(c, 0, 40, cat.Name, func(g pixel.Canvas, st pixel.State) {
fill := pixel.Face
if st.Hover {
fill = pixel.Lit
}
g.Frame(0, 0, g.W, g.H, pixel.Window, fill)
g.Sprite(6, 6, cat.Icon, 0, pixel.Accent, 2)
g.Text(34, 4, cat.Name, pixel.Ink, 1)
g.Text(34, 20, cat.Zh, pixel.Faint, 1)
num := fmt.Sprint(n)
g.Micro(g.W-pixel.MicroWidth(num)-6, g.H-10, num, pixel.Faint, 1)
}) {
a.Go("/cat/" + cat.Key)
}
}
})
}
func (a *App) category(c *ui.Context, key string) {
var cat Category
for _, x := range Categories {
if x.Key == key {
cat = x
}
}
if cat.Key == "" {
a.lost(c, "/cat/"+key)
return
}
k := pixel.K(c)
ui.Box(c).Height(k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Sprite(0, 4, cat.Icon, 0, pixel.Accent, 2)
shadowText(g, 30, 0, cat.Name, pixel.Ink, 2)
g.Text(30+pixel.Measure(cat.Name)*2+8, 14, cat.Zh, pixel.Faint, 1)
})
ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() {
for _, e := range Entries() {
if e.Category == key {
a.card(c, e)
}
}
})
}
func (a *App) card(c *ui.Context, e Entry) {
if press(c, 0, 36, e.Name, func(g pixel.Canvas, st pixel.State) {
fill := pixel.Face
if st.Hover {
fill = pixel.Lit
}
g.Frame(0, 0, g.W, g.H, pixel.Raised, fill)
if e.ID > 0 {
g.Micro(5, 4, fmt.Sprintf("%03d", e.ID), pixel.Accent, 1)
}
g.Text(5, 10, e.Name, pixel.Ink, 1)
g.Text(g.W-pixel.Measure(e.Zh)-5, 10, e.Zh, pixel.Faint, 1)
}) {
a.Go(e.Path())
}
}
func (a *App) entry(c *ui.Context, e Entry) {
k := pixel.K(c)
ui.Box(c).Height(k.D(36)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
x := 0
if e.ID > 0 {
g.Micro(0, 2, fmt.Sprintf("%03d", e.ID), pixel.Accent, 2)
x = 30
}
shadowText(g, x, 0, e.Name, pixel.Ink, 2)
g.Text(x+pixel.Measure(e.Name)*2+8, 14, e.Zh, pixel.Faint, 1)
})
pixel.Text(c, e.Summary, pixel.TextOptions{Wrap: true})
panel(c, pixel.Window, pixel.Face, 10).MinHeight(k.D(80)).Children(func() { e.Demo(c, a.logger(e)) })
code := strings.ReplaceAll(e.Code(), "\t", " ")
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Start).Children(func() {
panel(c, pixel.Sunken, pixel.Well, 4).Grow(1).Children(func() {
ui.ScrollBoth(c).Height(k.D(160)).Children(func() { codeBlock(c, code) })
})
ui.Column(c).Width(k.D(140)).Gap(k.D(4)).Children(func() {
if button(c, "COPY CODE") {
c.WriteClipboard(code)
a.logger(e)("code copied")
}
panel(c, pixel.Sunken, pixel.Well, 4).Height(k.D(140)).Children(func() {
for _, l := range a.logs[e.Slug()] {
pixel.Text(c, "> "+l, pixel.TextOptions{Slot: pixel.Faint, MaxLines: 1})
}
})
})
})
ui.Row(c).Gap(k.D(4)).Children(func() {
if button(c, "◀ PREV") {
a.step(-1)
}
if button(c, "NEXT ▶") {
a.step(1)
}
})
}
func codeBlock(c *ui.Context, code string) {
k := pixel.K(c)
lines := strings.Split(strings.TrimRight(code, "\n"), "\n")
w := 0
for _, l := range lines {
w = max(w, pixel.Measure(l))
}
ui.Box(c).Size(k.D(w), k.D(len(lines)*pixel.LineHeight)).Label(code).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, l := range lines {
ink := pixel.Ink
if strings.HasPrefix(strings.TrimSpace(l), "//") {
ink = pixel.Faint
}
g.Text(0, i*pixel.LineHeight, l, ink, 1)
}
})
}
func (a *App) results(c *ui.Context) {
found := matches(a.query)
pixel.Text(c, fmt.Sprintf("%d results for %q", len(found), strings.TrimSpace(a.query)), pixel.TextOptions{Slot: pixel.Faint})
k := pixel.K(c)
ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() {
for _, e := range found {
a.card(c, e)
}
})
}
// lost answers unknown paths with GAME OVER and CONTINUE.
func (a *App) lost(c *ui.Context, path string) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(8)).AlignItems(ui.Center).Padding(k.D(24)).Children(func() {
ui.Box(c).Size(k.D(pixel.Measure(k.Msg("game_over"))*3), k.D(48)).Draw(func(p *ui.Painter, r ui.Rect) {
shadowText(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3)
})
label(c, "No page at "+path, pixel.Faint)
pixel.Icon(c, icon.Skull, pixel.IconOptions{Scale: 3})
if button(c, k.Msg("continue")) {
a.Go("/")
}
})
}
+120
View File
@@ -0,0 +1,120 @@
// Package gallery shows every component live, in every palette.
package gallery
import (
"embed"
"fmt"
"reflect"
"runtime"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Entry is one page: a component or an engine foundation.
type Entry struct {
// ID is 1–500 for components, 0 for foundations.
ID int
Name, Zh string
Category string
Summary string
Demo func(c *ui.Context, log func(string))
}
type Category struct {
Key, Name, Zh string
Icon *pixel.Sprite
}
var Categories = []Category{
{"foundations", "Foundations", "基础", icon.Palette},
{"layout", "Layout", "布局", icon.Grid},
{"text", "Text", "文字", icon.FileText},
{"display", "Display", "展示", icon.Image},
{"input", "Input", "输入", icon.Edit},
{"feedback", "Feedback", "反馈", icon.Info},
{"navigation", "Navigation", "导航", icon.Map},
{"data", "Data", "数据", icon.List},
{"overlay", "Overlay", "浮层", icon.Copy},
{"datetime", "Date & Time", "日期时间", icon.Calendar},
{"chart", "Chart", "图表", icon.Sort},
{"game", "Game", "游戏", icon.Gamepad},
{"fx", "Effects", "特效", icon.Sparkle},
{"chat", "Chat", "聊天", icon.Chat},
{"media", "Media", "媒体", icon.Music},
{"code", "Code", "代码", icon.Code},
{"account", "Account", "账户", icon.User},
{"files", "Files", "文件", icon.Folder},
{"project", "Project", "项目", icon.Flag},
{"shop", "Shop", "商店", icon.Cart},
{"agent", "Agent", "智能体", icon.Robot},
{"desk", "Desk", "桌面", icon.Save},
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries)
func Entries() []Entry { return entries }
func (e Entry) Slug() string {
s := strings.ToLower(strings.ReplaceAll(e.Name, " ", "-"))
if e.ID == 0 {
return s
}
return fmt.Sprintf("%03d-%s", e.ID, s)
}
func (e Entry) Path() string {
if e.ID == 0 {
return "/f/" + e.Slug()
}
return "/c/" + e.Slug()
}
//go:embed demos_*.go
var demoFiles embed.FS
var codes = sources()
// Code is the source of the entry's demo function.
func (e Entry) Code() string {
src, ok := codes[funcName(e.Demo)]
if !ok {
panic("gallery: no source for " + funcName(e.Demo))
}
return src
}
func funcName(f any) string {
n := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
return n[strings.LastIndex(n, ".")+1:]
}
// sources indexes every top-level demo function by name.
func sources() map[string]string {
files, err := demoFiles.ReadDir(".")
if err != nil {
panic(err)
}
out := map[string]string{}
for _, f := range files {
b, err := demoFiles.ReadFile(f.Name())
if err != nil {
panic(err)
}
for _, part := range strings.Split(string(b), "\nfunc ")[1:] {
end := strings.Index(part, "\n}\n")
if end < 0 {
panic("gallery: unterminated func in " + f.Name())
}
if name := part[:strings.IndexAny(part, "( ")]; name != "" {
out[name] = "func " + part[:end+2]
}
}
}
return out
}
+74
View File
@@ -0,0 +1,74 @@
package gallery
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRegistry(t *testing.T) {
slugs, ids := map[string]bool{}, map[int]bool{}
known := map[string]bool{}
for _, c := range Categories {
known[c.Key] = true
}
for _, e := range Entries() {
if e.Name == "" || e.Zh == "" || e.Summary == "" || e.Demo == nil {
t.Fatalf("entry %+v is incomplete", e)
}
if !known[e.Category] {
t.Fatalf("%s: unknown category %q", e.Name, e.Category)
}
if (e.ID == 0) != (e.Category == "foundations") || e.ID < 0 || e.ID > 500 {
t.Fatalf("%s: id %d in %s", e.Name, e.ID, e.Category)
}
if slugs[e.Slug()] || e.ID > 0 && ids[e.ID] {
t.Fatalf("%s: duplicate slug or id", e.Slug())
}
slugs[e.Slug()], ids[e.ID] = true, true
if !strings.HasPrefix(e.Code(), "func "+funcName(e.Demo)+"(") {
t.Fatalf("%s: code snippet %q", e.Slug(), e.Code()[:min(40, len(e.Code()))])
}
}
}
// Every exported component function appears in a demo.
func TestEveryComponentHasADemo(t *testing.T) {
var demos strings.Builder
for _, e := range Entries() {
demos.WriteString(e.Code())
}
for _, cat := range Categories[1:] {
files, _ := filepath.Glob(filepath.Join("..", cat.Key, "*.go"))
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), f, src, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range file.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv != nil || !fn.Name.IsExported() || fn.Type.Params.NumFields() == 0 {
continue
}
if p, ok := fn.Type.Params.List[0].Type.(*ast.StarExpr); ok {
if sel, ok := p.X.(*ast.SelectorExpr); ok && sel.Sel.Name == "Context" {
if call := cat.Key + "." + fn.Name.Name + "("; !strings.Contains(demos.String(), call) {
t.Errorf("%s has no demo", call)
}
}
}
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package gallery
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// press is a clickable box; w ≤ 0 stretches.
func press(c *ui.Context, w, h int, label string, draw func(g pixel.Canvas, st pixel.State)) bool {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(label) })
if w > 0 {
shell.Width(k.D(w))
}
shell.Height(k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
draw(g, st)
if st.Focus {
g.FocusMark(0, 0, g.W, g.H)
}
})
return base.Clicked()
}
// button is a raised pixel button with a centered label.
func button(c *ui.Context, label string) bool {
return press(c, pixel.Measure(label)+12, 16, label, func(g pixel.Canvas, st pixel.State) {
y := 0
if st.Press {
y = 1
}
g.Frame(0, y, g.W, g.H-1, pixel.Raised, pixel.Face)
g.Text((g.W-pixel.Measure(label))/2, y, label, pixel.Ink, 1)
})
}
// panel is a framed box with padding for children.
func panel(c *ui.Context, style pixel.FrameStyle, fill pixel.Slot, pad int) *ui.Element {
k := pixel.K(c)
return ui.Column(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, style, fill)
})
}
// shadowText draws text with a one-dot shadow.
func shadowText(g pixel.Canvas, x, y int, s string, ink pixel.Slot, scale int) {
g.Text(x+scale, y+scale, s, pixel.Shade, scale)
g.Text(x, y, s, ink, scale)
}
// label is non-wrapping pixel text.
func label(c *ui.Context, s string, ink pixel.Slot) *ui.Element {
return pixel.Text(c, s, pixel.TextOptions{Slot: ink})
}