diff --git a/AGENTS.md b/AGENTS.md index 42b961f..ef04865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ 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)`. - 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. - Fail fast: panic on programmer errors such as bad options. Never swallow an error. - Composites log state changes: `slog.Debug("8bit: ", …)`. Never log secrets. diff --git a/cmd/gallery/main.go b/cmd/gallery/main.go new file mode 100644 index 0000000..8fde19f --- /dev/null +++ b/cmd/gallery/main.go @@ -0,0 +1,28 @@ +// Command gallery opens the 8bitMyGoUI gallery in a native window. +package main + +import ( + "log" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/gallery" +) + +func main() { + app := gallery.New("/") + mygo.App.WhenReady(func() { + mygo.NewWindow(mygo.WindowOptions{ + Title: "8bitMyGoUI", + Width: 1280, + Height: 800, + MinWidth: 640, + MinHeight: 400, + Content: ui.View(app.View), + }) + }) + if err := mygo.App.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/gallery/app.go b/gallery/app.go new file mode 100644 index 0000000..1ab80ae --- /dev/null +++ b/gallery/app.go @@ -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 +} diff --git a/gallery/app_test.go b/gallery/app_test.go new file mode 100644 index 0000000..4ed0f60 --- /dev/null +++ b/gallery/app_test.go @@ -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()) + } +} diff --git a/gallery/boot.go b/gallery/boot.go new file mode 100644 index 0000000..9ece291 --- /dev/null +++ b/gallery/boot.go @@ -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() + } +} diff --git a/gallery/chrome.go b/gallery/chrome.go new file mode 100644 index 0000000..60dd675 --- /dev/null +++ b/gallery/chrome.go @@ -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 +} diff --git a/gallery/demos_foundations.go b/gallery/demos_foundations.go new file mode 100644 index 0000000..e9155e4 --- /dev/null +++ b/gallery/demos_foundations.go @@ -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) + }) + }) +} diff --git a/gallery/invariants_test.go b/gallery/invariants_test.go new file mode 100644 index 0000000..99a89c6 --- /dev/null +++ b/gallery/invariants_test.go @@ -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) + } + } + } +} diff --git a/gallery/pages.go b/gallery/pages.go new file mode 100644 index 0000000..b357da8 --- /dev/null +++ b/gallery/pages.go @@ -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("/") + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go new file mode 100644 index 0000000..4336dec --- /dev/null +++ b/gallery/registry.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 +} diff --git a/gallery/registry_test.go b/gallery/registry_test.go new file mode 100644 index 0000000..0f49506 --- /dev/null +++ b/gallery/registry_test.go @@ -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) + } + } + } + } + } + } +} diff --git a/gallery/widgets.go b/gallery/widgets.go new file mode 100644 index 0000000..19aeb64 --- /dev/null +++ b/gallery/widgets.go @@ -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}) +} diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go new file mode 100644 index 0000000..0c614ab --- /dev/null +++ b/internal/archtest/archtest_test.go @@ -0,0 +1,222 @@ +// Package archtest checks the rules of AGENTS.md on every package. +package archtest + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" +) + +const module = "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/" + +var tiers = map[string]int{ + "pixel": 0, "icon": 1, + "layout": 2, "text": 2, "display": 2, + "input": 3, "feedback": 3, + "navigation": 4, "data": 4, "overlay": 4, "datetime": 4, "chart": 4, "fx": 4, + "game": 5, "chat": 5, "media": 5, "code": 5, "account": 5, "files": 5, + "project": 5, "shop": 5, "agent": 5, "desk": 5, "tabletop": 5, + "gallery": 6, "cmd/gallery": 7, "cmd/web": 7, +} + +// Styled ui widgets drawing outside the pixel style. +var bannedFuncs = set("Text Textf RichText Button PrimaryButton Link MenuButton Toggle ToggleGroup Segmented Toolbar " + + "Checkbox CheckboxGroup Switch Radio Select Slider StepSlider RangeSlider Stepper Rating TextInput TextArea " + + "NumberInput SearchField Combobox Autocomplete TokenField EditableText FindBar Calendar DateInput TimeInput " + + "ColorPicker ColorWell Table Tree TreeItem Accordion AccordionItem Collapsible Form Field Fieldset Tabs " + + "Sidebar SidebarItem SidebarSection Breadcrumbs BackButton ForwardButton Modal AlertDialog Popover Progress " + + "Spinner Meter Badge Image Icon Avatar Divider Split SplitVertical") + +// Element, Context and Painter methods that blend, ease or draw. +var bannedMethods = set("Background Border BorderColor BorderWidth BorderStyle Radius Shadow Gradient " + + "LinearGradient Opacity Stripes Tooltip Menu ContextMenu Transition Animate AnimateWith Loop Toast " + + "ToastAction AnimationFrame After FillPath StrokePath RichText Glyphs FillGradient Stroke StrokeDashed " + + "StrokePathGradient FillPathGradient Material Icon Image") + +func set(s string) map[string]bool { + out := map[string]bool{} + for _, f := range strings.Fields(s) { + out[f] = true + } + return out +} + +type goFile struct { + path, pkg string + ast *ast.File + src []byte +} + +// files parses every Go file of the module. +func files(t *testing.T) []goFile { + var out []goFile + err := filepath.WalkDir("../..", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && p != "../.." && (strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") { + return filepath.SkipDir + } + if d.IsDir() || !strings.HasSuffix(p, ".go") { + return nil + } + src, err := os.ReadFile(p) + if err != nil { + return err + } + f, err := parser.ParseFile(token.NewFileSet(), p, src, parser.ParseComments) + if err != nil { + return err + } + rel, _ := filepath.Rel("../..", filepath.Dir(p)) + out = append(out, goFile{p, filepath.ToSlash(rel), f, src}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(out) < 20 { + t.Fatalf("found %d Go files; the walk is broken", len(out)) + } + return out +} + +func TestImportsPointDown(t *testing.T) { + for _, f := range files(t) { + if strings.HasSuffix(f.path, "_test.go") || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") { + continue + } + tier, ok := tiers[f.pkg] + if !ok { + t.Errorf("%s: package %q has no tier in AGENTS.md", f.path, f.pkg) + continue + } + for _, im := range f.ast.Imports { + path, _ := strconv.Unquote(im.Path.Value) + if !strings.HasPrefix(path, module) { + continue + } + to := strings.TrimPrefix(path, module) + if got, ok := tiers[to]; !ok || got >= tier { + t.Errorf("%s: %s (tier %d) imports %s (tier %d)", f.path, f.pkg, tier, to, got) + } + } + } +} + +func TestNoBannedCalls(t *testing.T) { + for _, f := range files(t) { + if strings.HasSuffix(f.path, "_test.go") || f.pkg == "pixel" || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") { + continue + } + imported := map[string]string{} + for _, im := range f.ast.Imports { + path, _ := strconv.Unquote(im.Path.Value) + name := path[strings.LastIndex(path, "/")+1:] + if im.Name != nil { + name = im.Name.Name + } + imported[name] = path + } + painters := map[*ast.Ident]bool{} + var stack []ast.Node + ast.Inspect(f.ast, func(n ast.Node) bool { + if n == nil { + stack = stack[:len(stack)-1] + return true + } + var parent ast.Node + if len(stack) > 0 { + parent = stack[len(stack)-1] + } + stack = append(stack, n) + switch n := n.(type) { + case *ast.Field: + if star, ok := n.Type.(*ast.StarExpr); ok && isUI(star.X, "Painter", imported) { + for _, name := range n.Names { + painters[name] = true + } + } + case *ast.Ident: + if n.Obj != nil && n.Obj.Kind == ast.Var && !painters[n] && isPainter(n, painters) && !intoCanvas(n, parent) { + t.Errorf("%s: painter %s used outside k.Canvas; only pixel draws", f.path, n.Name) + } + case *ast.CallExpr: + sel, ok := n.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + pkg := false + if id, ok := sel.X.(*ast.Ident); ok { + _, pkg = imported[id.Name] + } + if pkg && isUI(sel.X, "", imported) && bannedFuncs[sel.Sel.Name] || !pkg && bannedMethods[sel.Sel.Name] { + t.Errorf("%s: banned call %s", f.path, sel.Sel.Name) + } + } + return true + }) + } +} + +// isPainter reports whether id refers to a *ui.Painter parameter. +func isPainter(id *ast.Ident, painters map[*ast.Ident]bool) bool { + for p := range painters { + if id.Obj == p.Obj { + return true + } + } + return false +} + +// intoCanvas reports whether id is a Canvas call argument. +func intoCanvas(id *ast.Ident, parent ast.Node) bool { + call, ok := parent.(*ast.CallExpr) + if !ok { + return false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + return ok && sel.Sel.Name == "Canvas" && slices.Contains(call.Args, ast.Expr(id)) +} + +// isUI reports whether x names package ui, or ui.name. +func isUI(x ast.Expr, name string, imported map[string]string) bool { + if sel, ok := x.(*ast.SelectorExpr); ok && name != "" { + return sel.Sel.Name == name && isUI(sel.X, "", imported) + } + id, ok := x.(*ast.Ident) + return ok && name == "" && imported[id.Name] == "github.com/egoist/mygo/ui" +} + +var directive = regexp.MustCompile(`^//(go:|line )`) + +func TestCommentsAndFileLength(t *testing.T) { + for _, f := range files(t) { + if n := strings.Count(string(f.src), "\n"); n >= 500 { + t.Errorf("%s: %d lines, keep files under 500", f.path, n) + } + for _, g := range f.ast.Comments { + lines := 0 + for _, c := range g.List { + if directive.MatchString(c.Text) || c.Text == "//" { + continue + } + lines++ + if words := len(strings.Fields(strings.TrimPrefix(c.Text, "//"))); words > 10 || strings.HasPrefix(c.Text, "/*") { + t.Errorf("%s: comment of %d words: %s", f.path, words, c.Text) + } + } + if lines > 1 { + t.Errorf("%s: comment of %d lines near %q", f.path, lines, g.List[0].Text) + } + } + } +} diff --git a/pixel/editor.go b/pixel/editor.go index 87e4a76..f61fb18 100644 --- a/pixel/editor.go +++ b/pixel/editor.go @@ -20,7 +20,8 @@ type EditorOptions struct { } type Edit struct { - Element *ui.Element + // Element is laid out; Input takes the focus. + Element, Input *ui.Element Changed, Submitted, Focused bool } @@ -78,7 +79,10 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit { } return took }) - res := Edit{Element: shell, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused()} + res := Edit{Element: shell, Input: base, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused()} + if res.Focused { + frameOf(c).typing = true + } if s.submitted { slog.Debug("8bit: editor submit", "label", o.Label) } diff --git a/pixel/editor_test.go b/pixel/editor_test.go index 14bcb3d..7a88507 100644 --- a/pixel/editor_test.go +++ b/pixel/editor_test.go @@ -267,3 +267,52 @@ func TestEditorDrawsCaretAndSelection(t *testing.T) { t.Fatal("selection not drawn in Accent with AccentInk text") } } + +func TestEditorOwnsTypingKeys(t *testing.T) { + value, shortcuts := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 2}) + pixel.Editor(c, &value, pixel.EditorOptions{Label: "field"}) + if pixel.Typing(c) { + return + } + for _, key := range []ui.Key{ui.KeyP, ui.KeySlash, ui.KeyBracketRight, ui.KeySpace} { + if c.Shortcut(0, key) { + shortcuts++ + } + } + }, 300, 100) + if err := tt.Click("field"); err != nil { + t.Fatal(err) + } + tt.TypeKey(0, ui.KeyP, "p") + tt.TypeKey(0, ui.KeySlash, "/") + tt.TypeKey(0, ui.KeyBracketRight, "]") + tt.TypeKey(0, ui.KeySpace, " ") + if value != "p/] " || shortcuts != 0 { + t.Fatalf("value %q, shortcuts fired %d times", value, shortcuts) + } + tt.ClickAt(150, 90) + tt.Key(0, ui.KeyP) + if shortcuts != 1 { + t.Fatalf("after leaving the field, P fired %d shortcuts", shortcuts) + } +} + +func TestEditorSpaceDoesNotScroll(t *testing.T) { + value, scroll := "", ui.ScrollState{} + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 2}) + ui.Scroll(c).Height(100).TrackScroll(&scroll).Children(func() { + pixel.Editor(c, &value, pixel.EditorOptions{Label: "field"}) + ui.Box(c).Height(1000) + }) + }, 300, 100) + if err := tt.Click("field"); err != nil { + t.Fatal(err) + } + tt.TypeKey(0, ui.KeySpace, " ") + if value != " " || scroll.Y != 0 { + t.Fatalf("value %q, scrolled to %v", value, scroll.Y) + } +} diff --git a/pixel/settings.go b/pixel/settings.go index 7f3c528..c4b87d5 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -43,8 +43,9 @@ func (k Kit) Now() time.Time { return k.now } func (k Kit) Reduce() bool { return k.reduce } type frame struct { - cur Settings - at time.Time + cur Settings + at time.Time + typing bool } type frameKey struct{} @@ -56,10 +57,13 @@ func Use(c *ui.Context, s Settings) { if f.cur.Palette != s.Palette { slog.Debug("8bit: palette", "name", s.Palette.Name) } - f.cur, f.at = s, c.Now() + f.cur, f.at, f.typing = s, c.Now(), false c.SetTheme(theme(s.Palette)) } +// Typing reports a focused Editor built so far this frame. +func Typing(c *ui.Context) bool { return current(c).typing } + // Scope builds fn under other settings. func Scope(c *ui.Context, s Settings, fn func()) { f := current(c)