Add gallery app, native window and architecture tests
This commit is contained in:
16 files changed
+1552
-5
No files matched your search
+125
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("/")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user