From 0247a130f539c54f32d6e53fca808e4bdf016ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 18:37:54 -0400 Subject: [PATCH] Add display components 041-065; move Icon into display; QR via rsc.io/qr --- AGENTS.md | 1 + display/avatar.go | 149 ++++++++++++++++++ display/badge.go | 39 +++++ display/bubble.go | 73 +++++++++ display/display.go | 54 +++++++ display/display_test.go | 330 +++++++++++++++++++++++++++++++++++++++ display/emoji.go | 80 ++++++++++ display/figure.go | 21 +++ display/flag.go | 35 +++++ display/gem.go | 74 +++++++++ display/icon.go | 20 +++ display/internal_test.go | 90 +++++++++++ display/keyvalue.go | 24 +++ display/medal.go | 61 ++++++++ display/picture.go | 113 ++++++++++++++ display/placeholder.go | 36 +++++ display/portrait.go | 31 ++++ display/qrcode.go | 75 +++++++++ display/rarityframe.go | 64 ++++++++ display/ribbon.go | 48 ++++++ display/signpost.go | 93 +++++++++++ display/sprite.go | 32 ++++ display/stamp.go | 49 ++++++ display/stat.go | 46 ++++++ display/statusdot.go | 48 ++++++ display/swatch.go | 28 ++++ display/tag.go | 76 +++++++++ display/trophy.go | 51 ++++++ gallery/demos_display.go | 272 ++++++++++++++++++++++++++++++++ gallery/demos_layout.go | 7 +- gallery/pages.go | 3 +- gallery/registry.go | 2 +- go.mod | 5 +- go.sum | 2 + icon/icon_test.go | 3 +- pixel/dither.go | 3 + pixel/element.go | 21 --- pixel/element_test.go | 16 -- pixel/messages.go | 6 + pixel/micro.go | 10 ++ pixel/palette.go | 5 + 41 files changed, 2152 insertions(+), 44 deletions(-) create mode 100644 display/avatar.go create mode 100644 display/badge.go create mode 100644 display/bubble.go create mode 100644 display/display.go create mode 100644 display/display_test.go create mode 100644 display/emoji.go create mode 100644 display/figure.go create mode 100644 display/flag.go create mode 100644 display/gem.go create mode 100644 display/icon.go create mode 100644 display/internal_test.go create mode 100644 display/keyvalue.go create mode 100644 display/medal.go create mode 100644 display/picture.go create mode 100644 display/placeholder.go create mode 100644 display/portrait.go create mode 100644 display/qrcode.go create mode 100644 display/rarityframe.go create mode 100644 display/ribbon.go create mode 100644 display/signpost.go create mode 100644 display/sprite.go create mode 100644 display/stamp.go create mode 100644 display/stat.go create mode 100644 display/statusdot.go create mode 100644 display/swatch.go create mode 100644 display/tag.go create mode 100644 display/trophy.go create mode 100644 gallery/demos_display.go diff --git a/AGENTS.md b/AGENTS.md index 710af95..1454b57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Progress truth: `TASKS.md`. Requirements: `PRD.md` and `docs/prd/components-*.md | Go | 1.27.1 | | MyGo | `github.com/egoist/mygo v0.2.10`, pinned in go.mod | | Font | Ark Pixel Font 12px proportional zh_Hans v2026.09.25 (OFL-1.1, `pixel/OFL.txt`). Rebuild: `go run ./tools/fontpack -in ark-pixel-12px-proportional-zh_hans.bdf` (BDF sha256 `535f7024…8c86`) | +| QR | `rsc.io/qr v0.2.0` (BSD-3), encoder behind `display.QRCode` | | Web | `cmd/web` built with `GOOS=js GOARCH=wasm`; the browser runs the gallery under `ui.Tester` and blits frames to a canvas | | Hosting | Cloudflare Workers static assets from `web/dist`; code on Gitea `git.zacharyzhang.com` | diff --git a/display/avatar.go b/display/avatar.go new file mode 100644 index 0000000..36e7dee --- /dev/null +++ b/display/avatar.go @@ -0,0 +1,149 @@ +package display + +import ( + "hash/fnv" + "image" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var ( + head = pixel.MustSprite(` + ................ + ................ + ................ + ....eeeeeeee.... + ...e########e... + ..e##########e.. + ..e##########e.. + ..e##########e.. + ..e##########e.. + ..e##########e.. + ..e##########e.. + ..e##########e.. + ...e########e... + ....e######e.... + .....eeeeee..... + ................ + `) + hair = []*pixel.Sprite{pixel.MustSprite(` + ................ + ................ + ....########.... + ...##########... + ..############.. + ..##........##.. + ..#..........#.. + `), pixel.MustSprite(` + ................ + ....#.#..#.#.... + ...##########... + ..############.. + ..###.####.###.. + ..#..........#.. + `), pixel.MustSprite(` + ................ + ................ + ....########.... + ...##########... + ..############.. + ..###......###.. + ..##........##.. + ..##........##.. + ..##........##.. + ..##........##.. + ..##........##.. + ...#........#... + `)} + eyes = []*pixel.Sprite{pixel.MustSprite(` + .....#....#..... + .....#....#..... + `), pixel.MustSprite(` + ....##....##.... + ................ + `), pixel.MustSprite(` + ....#.#..#.#.... + .....#....#..... + `)} + mouths = []*pixel.Sprite{pixel.MustSprite(` + ......#..#...... + .......##....... + `), pixel.MustSprite(` + ................ + ......####...... + `), pixel.MustSprite(` + .......##....... + .......##....... + `)} + backs = []pixel.Slot{pixel.Info, pixel.Good, pixel.Magic, pixel.Warn, pixel.AccentLit, pixel.Accent} + skins = []pixel.Slot{pixel.Lit, pixel.Face, pixel.Shade, pixel.Well} + colors = []pixel.Slot{pixel.Ink, pixel.Edge, pixel.Bad, pixel.Warn, pixel.Magic, pixel.Faint, pixel.Info} +) + +type AvatarOptions struct { + Scale int + // Picture replaces the drawn face. + Picture image.Image +} + +// Avatar draws a face seeded by name, or a picture. +func Avatar(c *ui.Context, name string, o AvatarOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + if o.Picture != nil { + return Picture(c, o.Picture, PictureOptions{W: 16 * sc, H: 16 * sc, Label: name}) + } + return art(c, k, name, 16*sc, 16*sc, func(g pixel.Canvas) { drawFace(g, 0, name, sc) }) +} + +// drawFace paints the face of name at x, scaled. +func drawFace(g pixel.Canvas, x int, name string, sc int) { + h := fnv.New32a() + h.Write([]byte(strings.ToLower(name))) + n := h.Sum32() + pick := func(k int) int { v := int(n % uint32(k)); n /= uint32(k); return v } + g.Rect(x, 0, 16*sc, 16*sc, backs[pick(len(backs))]) + g.Sprite(x, 0, head, 0, skins[pick(len(skins))], sc) + g.Sprite(x, 0, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc) + g.Sprite(x, 8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc) + g.Sprite(x, 11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc) +} + +type AvatarGroupOptions struct { + // Max faces shown before +N; zero means 4. + Max int + Scale int +} + +// AvatarGroup overlaps faces and counts the rest in a chip. +func AvatarGroup(c *ui.Context, names []string, o AvatarGroupOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + shown := names[:min(len(names), orDef("AvatarGroup Max", o.Max, 4))] + rest := len(names) - len(shown) + more := "" + if rest > 0 { + more = "+" + itoa(rest) + } + step := 12 * sc + w := max(0, len(shown)-1)*step + 16*sc + if more != "" { + w += pixel.MicroWidth(more) + 8 + } + return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) { + for i, name := range shown { + x := i * step + drawFace(g, x, name, sc) + if i > 0 { + g.VLine(x, 0, 16*sc, pixel.Back) + } + } + if more != "" { + x := len(shown)*step + 4*sc + cw := pixel.MicroWidth(more) + 4 + g.Frame(x, (16*sc-9)/2, cw, 9, pixel.Notched, pixel.Face) + g.Micro(x+2, (16*sc-9)/2+2, more, pixel.Ink, 1) + } + }) +} diff --git a/display/badge.go b/display/badge.go new file mode 100644 index 0000000..97d3031 --- /dev/null +++ b/display/badge.go @@ -0,0 +1,39 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type BadgeOptions struct { + // Text replaces the count. + Text string + // Slot is the fill; zero means Bad. + Slot pixel.Slot +} + +// Badge shows n, capped at 99+, or text. +func Badge(c *ui.Context, n int, o BadgeOptions) *ui.Element { + k := pixel.K(c) + s := o.Text + if s == "" { + s = itoa(n) + if n > 99 { + s = "99+" + } + } + fill := slotOr(o.Slot, pixel.Bad) + if !pixel.MicroOK(s) { + w := pixel.Measure(s) + 6 + return art(c, k, s, w, pixel.LineHeight+1, func(g pixel.Canvas) { + g.Frame(0, 0, w, pixel.LineHeight+1, pixel.Notched, fill) + g.Text(3, 0, s, pixel.AccentInk, 1) + }) + } + w := max(pixel.MicroWidth(s)+6, 9) + return art(c, k, s, w, 9, func(g pixel.Canvas) { + g.Frame(0, 0, w, 9, pixel.Notched, fill) + g.Micro((w-pixel.MicroWidth(s))/2, 2, s, pixel.AccentInk, 1) + }) +} diff --git a/display/bubble.go b/display/bubble.go new file mode 100644 index 0000000..fcbc911 --- /dev/null +++ b/display/bubble.go @@ -0,0 +1,73 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Side uint8 + +const ( + Bottom Side = iota + Top + Left + Right +) + +type BubbleOptions struct { + // Tail is the side the tail leaves from. + Tail Side + // At offsets the tail along its side; zero means 8. + At int +} + +const tailLen = 5 + +// Bubble is a speech bubble with a tail around children. +func Bubble(c *ui.Context, o BubbleOptions, children func()) *ui.Element { + if o.Tail > Right { + panic(fmt.Sprintf("display: unknown Side %d", o.Tail)) + } + k := pixel.K(c) + at := orDef("Bubble At", o.At, 8) + pad := [4]int{6, 8, 6, 8} + pad[[...]int{Bottom: 2, Top: 0, Left: 3, Right: 1}[o.Tail]] += tailLen + return ui.Column(c).Padding(k.D(pad[0]), k.D(pad[1]), k.D(pad[2]), k.D(pad[3])).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x, y, w, h := 0, 0, g.W, g.H + switch o.Tail { + case Bottom: + h -= tailLen + case Top: + y, h = tailLen, h-tailLen + case Left: + x, w = tailLen, w-tailLen + case Right: + w -= tailLen + } + if w < 6 || h < 6 { + return + } + g.Frame(x, y, w, h, pixel.Notched, pixel.Lit) + for i := range tailLen { + n := tailLen - i + switch o.Tail { + case Bottom: + g.HLine(x+at-1, y+h-1+i, n+1, pixel.Edge) + g.HLine(x+at, y+h-1+i, n-1, pixel.Lit) + case Top: + g.HLine(x+at-1, y-i, n+1, pixel.Edge) + g.HLine(x+at, y-i, n-1, pixel.Lit) + case Left: + g.VLine(x-i, y+at-1, n+1, pixel.Edge) + g.VLine(x-i, y+at, n-1, pixel.Lit) + case Right: + g.VLine(x+w-1+i, y+at-1, n+1, pixel.Edge) + g.VLine(x+w-1+i, y+at, n-1, pixel.Lit) + } + } + }).Children(children) +} diff --git a/display/display.go b/display/display.go new file mode 100644 index 0000000..318dd48 --- /dev/null +++ b/display/display.go @@ -0,0 +1,54 @@ +// Package display shows things: pictures, faces, badges and prizes. +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// art is a labeled w×h dot image that fn draws. +func art(c *ui.Context, k pixel.Kit, label string, w, h int, fn func(g pixel.Canvas)) *ui.Element { + e := ui.Box(c).Size(k.D(w), k.D(h)) + if label != "" { + e.Label(label).Role(ui.RoleImage) + } else { + e.Role(ui.RoleNone) + } + if fn != nil { + e.Draw(func(p *ui.Painter, r ui.Rect) { fn(k.Canvas(p, r)) }) + } + return e +} + +// orDef is v, or def for zero; negative panics. +func orDef(name string, v, def int) int { + if v < 0 { + panic(fmt.Sprintf("display: %s %d is negative", name, v)) + } + if v == 0 { + return def + } + return v +} + +// scale is s, or 1 for zero; outside 1–4 panics. +func scale(s int) int { + s = orDef("Scale", s, 1) + if s > 4 { + panic(fmt.Sprintf("display: Scale %d outside 1–4", s)) + } + return s +} + +// slotOr is s, or def for the zero slot. +func slotOr(s, def pixel.Slot) pixel.Slot { + if s == pixel.Ink { + return def + } + return s +} + +func itoa(n int) string { return fmt.Sprint(n) } diff --git a/display/display_test.go b/display/display_test.go new file mode 100644 index 0000000..f8b8b53 --- /dev/null +++ b/display/display_test.go @@ -0,0 +1,330 @@ +package display_test + +import ( + "image" + "image/color" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +func newClock() *clock { return &clock{time.Unix(2_000_000, 0)} } + +type env struct { + pal *pixel.Palette + locale pixel.Locale + clk *clock +} + +func view(w, h int, e *env, build func(c *ui.Context)) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: e.pal, Locale: e.locale, Now: e.clk.now}) + build(c) + }, w, h) +} + +func spring() *env { return &env{pixel.SpringDay, pixel.En, newClock()} } + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +// slots maps a box's dots to palette slots, or fails. +func slots(t *testing.T, img *image.RGBA, pal *pixel.Palette, r ui.Rect) []pixel.Slot { + t.Helper() + var out []pixel.Slot + for y := int(r.Y); y < int(r.Y+r.H); y += 2 { + for x := int(r.X); x < int(r.X+r.W); x += 2 { + c, found := img.RGBAAt(x, y), false + for s, pc := range pal.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + out, found = append(out, pixel.Slot(s)), true + break + } + } + if !found { + t.Fatalf("pixel %d,%d off %s: %v", x, y, pal.Name, c) + } + } + } + return out +} + +func has(s []pixel.Slot, want pixel.Slot) bool { + for _, v := range s { + if v == want { + return true + } + } + return false +} + +func TestIconLabels(t *testing.T) { + tt := view(200, 100, spring(), func(c *ui.Context) { + display.Icon(c, icon.Heart, display.IconOptions{Scale: 3, Label: "heart"}) + display.Icon(c, icon.Star, display.IconOptions{}) + }) + if r := find(t, tt, "heart"); r.W != float32(icon.Heart.W*3*2) { + t.Fatalf("icon box %v", r) + } + if len(tt.Texts()) != 1 { + t.Fatalf("an unlabeled icon reached assistive technology: %q", tt.Texts()) + } +} + +func TestSpritePlaysAndMirrors(t *testing.T) { + e := spring() + sp := pixel.MustSprite("r.\n..\n\n.r\n..") + tt := view(100, 100, e, func(c *ui.Context) { + ui.Column(c).Children(func() { + display.Sprite(c, sp, display.SpriteOptions{FPS: 1, Label: "s"}) + display.Sprite(c, sp, display.SpriteOptions{Mirror: true, Label: "m"}) + }) + }) + at := func(label string) []pixel.Slot { return slots(t, tt.Image(), e.pal, find(t, tt, label)) } + f0 := at("s") + if f0[0] != pixel.Bad || at("m")[1] != pixel.Bad { + t.Fatalf("frame 0 %v, mirrored %v", f0, at("m")) + } + e.clk.t = e.clk.t.Add(time.Second) + tt.Frame() + if at("s")[1] != pixel.Bad { + t.Fatal("the sprite did not play its second frame") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true}) + if at("s")[0] != pixel.Bad { + t.Fatal("Reduce Motion did not hold the first frame") + } +} + +func TestPictureRecolors(t *testing.T) { + e := spring() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for i := range img.Pix { + img.Pix[i] = 200 + } + tt := view(100, 100, e, func(c *ui.Context) { display.Picture(c, img, display.PictureOptions{Label: "pic"}) }) + day := slots(t, tt.Image(), e.pal, find(t, tt, "pic")) + e.pal = pixel.WinterNight + tt.Frame() + night := slots(t, tt.Image(), e.pal, find(t, tt, "pic")) + // Quantized afresh, a gray lands on other slots at night. + if slices.Equal(day, night) { + t.Fatalf("the picture kept its day slots at night: %v", night[:4]) + } +} + +// listImage holds a slice, so its values do not compare. +type listImage struct{ px []color.RGBA } + +func (l listImage) ColorModel() color.Model { return color.RGBAModel } +func (l listImage) Bounds() image.Rectangle { return image.Rect(0, 0, 4, 4) } +func (l listImage) At(x, y int) color.Color { return l.px[y*4+x] } + +func TestPictureFollowsEditsAndAnyImage(t *testing.T) { + e := spring() + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + list := listImage{make([]color.RGBA, 16)} + for i := range list.px { + list.px[i] = color.RGBA{0, 0, 0, 255} + } + tt := view(200, 100, e, func(c *ui.Context) { + display.Picture(c, img, display.PictureOptions{Label: "rgba"}) + display.Picture(c, list, display.PictureOptions{Label: "list"}) + }) + before := slots(t, tt.Image(), e.pal, find(t, tt, "rgba")) + for i := range img.Pix { + img.Pix[i] = 255 + } + tt.Frame() + if slices.Equal(before, slots(t, tt.Image(), e.pal, find(t, tt, "rgba"))) { + t.Fatal("editing the image in place did not redraw it") + } + if got := slots(t, tt.Image(), e.pal, find(t, tt, "list")); got[0] != pixel.Ink { + t.Fatalf("a black list image drew %v", got[0]) + } +} + +func TestBadgeAndTrophyTakeAnyText(t *testing.T) { + tt := view(400, 200, spring(), func(c *ui.Context) { + display.Badge(c, 0, display.BadgeOptions{Text: "新"}) + display.Badge(c, 7, display.BadgeOptions{}) + display.Trophy(c, "冠军 É", display.TrophyOptions{}) + }) + e := spring() + if cjk, digit := find(t, tt, "新"), find(t, tt, "7"); cjk.H <= digit.H { + t.Fatalf("the CJK badge %v is no taller than the micro one %v", cjk, digit) + } + if !has(slots(t, tt.Image(), e.pal, find(t, tt, "新")), pixel.AccentInk) { + t.Fatal("the CJK badge drew no text") + } + // The cup has no Ink; only the plaque's words do. + if !has(slots(t, tt.Image(), e.pal, find(t, tt, "冠军 É")), pixel.Ink) { + t.Fatal("the trophy plaque drew no text") + } +} + +func TestSignpostIsOneTabStop(t *testing.T) { + tt := view(300, 300, spring(), func(c *ui.Context) { + display.Signpost(c, []string{"Town", "Cave", "Shop"}, display.SignpostOptions{}) + ui.ButtonBase(c).Label("after").Size(20, 20) + }) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Town") { + t.Fatal("Tab did not enter at the first board") + } + tt.Key(0, ui.KeyDown) + if !tt.Focused("Cave") { + t.Fatal("Down did not move to the next board") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("after") { + t.Fatal("Tab did not leave the signpost") + } +} + +func TestBadgeTagStatus(t *testing.T) { + e := spring() + removed, disabled := 0, false + tt := view(400, 200, e, func(c *ui.Context) { + display.Badge(c, 128, display.BadgeOptions{}) + display.Badge(c, 5, display.BadgeOptions{Text: "NEW"}) + if display.Tag(c, "fire", display.TagOptions{Removable: true, Disabled: disabled}).Removed { + removed++ + } + display.StatusDot(c, display.Online, display.StatusDotOptions{Live: true}) + }) + find(t, tt, "99+") + find(t, tt, "NEW") + if err := tt.Click("REMOVE fire"); err != nil || removed != 1 { + t.Fatalf("remove: %v, %d", err, removed) + } + disabled = true + tt.Frame() + _ = tt.Click("REMOVE fire") + if removed != 1 { + t.Fatal("a disabled tag was removed") + } + dot := func() []pixel.Slot { return slots(t, tt.Image(), e.pal, find(t, tt, "ONLINE")) } + if !has(dot(), pixel.Good) { + t.Fatal("step 0 hid the online light") + } + e.clk.t = e.clk.t.Add(500 * time.Millisecond) + tt.Frame() + if has(dot(), pixel.Good) { + t.Fatal("a live online dot did not blink") + } + tt.SetPreferences(ui.Preferences{ReduceMotion: true}) + if !has(dot(), pixel.Good) { + t.Fatal("Reduce Motion left the light off") + } + e.locale = pixel.Zh + tt.Frame() + find(t, tt, "在线") +} + +func TestSignpostBoards(t *testing.T) { + clicked, disabled := -1, false + tt := view(300, 300, spring(), func(c *ui.Context) { + if r := display.Signpost(c, []string{"Town", "Cave", "Shop"}, display.SignpostOptions{Disabled: disabled}); r.Clicked >= 0 { + clicked = r.Clicked + } + }) + if err := tt.Click("Cave"); err != nil || clicked != 1 { + t.Fatalf("clicked %d, %v", clicked, err) + } + disabled, clicked = true, -1 + tt.Frame() + _ = tt.Click("Shop") + if clicked != -1 { + t.Fatal("a disabled board clicked") + } + for _, n := range []int{0, 4} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%d boards did not panic", n) + } + }() + view(100, 100, spring(), func(c *ui.Context) { display.Signpost(c, make([]string, n), display.SignpostOptions{}) }) + }() + } +} + +func TestQRCodeQuietZoneAndFinder(t *testing.T) { + e := spring() + tt := view(400, 400, e, func(c *ui.Context) { display.QRCode(c, "HELLO", display.QRCodeOptions{Scale: 1}) }) + r := find(t, tt, "HELLO") + s := slots(t, tt.Image(), e.pal, r) + n := int(r.W / 2) + if n != 21+8 { + t.Fatalf("version 1 code with quiet zone is %d modules, want 29", n) + } + at := func(x, y int) pixel.Slot { return s[y*n+x] } + for i := range 4 { + if at(i, i) != pixel.AccentInk || at(n-1-i, i) != pixel.AccentInk { + t.Fatal("the quiet zone is not light") + } + } + for i := range 7 { + if at(4+i, 4) != pixel.Ink || at(4, 4+i) != pixel.Ink || at(4+i, 10) != pixel.Ink { + t.Fatal("the top-left finder is not a dark ring") + } + } + if at(5, 5) != pixel.AccentInk || at(7, 7) != pixel.Ink { + t.Fatal("the finder's gap or center is wrong") + } +} + +func TestBubbleTailMovesContent(t *testing.T) { + side := display.Bottom + tt := view(300, 200, spring(), func(c *ui.Context) { + display.Bubble(c, display.BubbleOptions{Tail: side}, func() { pixel.Text(c, "HI", pixel.TextOptions{}) }) + }) + below := find(t, tt, "HI") + side = display.Top + tt.Frame() + if above := find(t, tt, "HI"); above.Y != below.Y+5*2 { + t.Fatalf("a top tail put the text at %v, bottom at %v", above.Y, below.Y) + } + side = display.Left + tt.Frame() + if left := find(t, tt, "HI"); left.X != below.X+5*2 { + t.Fatalf("a left tail put the text at %v", left.X) + } +} + +func TestPanicsOnBadInput(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "emoji": func(c *ui.Context) { display.Emoji(c, "nope", display.EmojiOptions{}) }, + "status": func(c *ui.Context) { display.StatusDot(c, 9, display.StatusDotOptions{}) }, + "medal": func(c *ui.Context) { display.Medal(c, 9, display.MedalOptions{}) }, + "side": func(c *ui.Context) { display.Bubble(c, display.BubbleOptions{Tail: 9}, func() {}) }, + "scale": func(c *ui.Context) { display.Icon(c, icon.Star, display.IconOptions{Scale: 5}) }, + "qr long": func(c *ui.Context) { display.QRCode(c, string(make([]byte, 4000)), display.QRCodeOptions{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + view(200, 200, spring(), build) + }() + } +} diff --git a/display/emoji.go b/display/emoji.go new file mode 100644 index 0000000..f493b89 --- /dev/null +++ b/display/emoji.go @@ -0,0 +1,80 @@ +package display + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var faceBase = []string{ + "...eeeeee...", + "..eyyyyyye..", + ".eyyyyyyyye.", + "eyyyyyyyyyye", + "eyyyyyyyyyye", + "eyyyyyyyyyye", + "eyyyyyyyyyye", + "eyyyyyyyyyye", + "eyyyyyyyyyye", + ".eyyyyyyyye.", + "..eyyyyyye..", + "...eeeeee...", +} + +// EmojiNames lists the faces in order. +var EmojiNames = []string{"smile", "grin", "laugh", "wink", "love", "cool", "wow", "sad", + "cry", "angry", "sleep", "sick", "dead", "think", "meh", "blush"} + +// features are each face's rows over the base. +var features = map[string]map[int]string{ + "smile": {4: "...k....k...", 5: "...k....k...", 7: "..k......k..", 8: "...kkkkkk..."}, + "grin": {4: "...k....k...", 5: "...k....k...", 7: "..kkkkkkkk..", 8: "..kllllllk..", 9: "...kkkkkk..."}, + "laugh": {4: "...k....k...", 5: "..k.k..k.k..", 7: "..kkkkkkkk..", 8: "..krrrrrrk..", 9: "...kkkkkk..."}, + "wink": {4: "........k...", 5: "..kkk...k...", 7: "..k......k..", 8: "...kkkkkk..."}, + "love": {3: "..r.r..r.r..", 4: "..rrr..rrr..", 5: "...r....r...", 7: "..k......k..", 8: "...kkkkkk..."}, + "cool": {4: ".kkkkkkkkkk.", 5: "..kkk..kkk..", 7: "..k.........", 8: "...kkkkk...."}, + "wow": {4: "..kk....kk..", 5: "..kk....kk..", 7: ".....kk.....", 8: "....k..k....", 9: ".....kk....."}, + "sad": {4: "...k....k...", 5: "...k....k...", 8: "...kkkkkk...", 9: "..k......k.."}, + "cry": {4: "..kkk..kkk..", 5: "..c......c..", 6: "..c......c..", 8: "...kkkkkk...", 9: "..k......k.."}, + "angry": {3: "..k......k..", 4: "...kk..kk...", 5: "...k....k...", 8: "...kkkkkk...", 9: "..k......k.."}, + "sleep": {0: ".........kkk", 1: "..........k.", 2: ".........kkk", 5: "..kkk..kkk..", 8: "....kkkk...."}, + "sick": {4: "..kkk..kkk..", 5: "...k....k...", 7: "..g.g.g.g...", 8: "...g.g.g.g..", 9: "...kk..kk..."}, + "dead": {3: "..k.k..k.k..", 4: "...k....k...", 5: "..k.k..k.k..", 8: "...kkkkkk..."}, + "think": {3: "..kkk.......", 4: "...k....k...", 5: "...k....k...", 8: ".....kkk...."}, + "meh": {4: "...k....k...", 5: "...k....k...", 8: "...kkkkkk..."}, + "blush": {4: "...k....k...", 5: "...k....k...", 6: ".AA......AA.", 7: "..k......k..", 8: "...kkkkkk..."}, +} + +var emoji = func() map[string]*pixel.Sprite { + out := map[string]*pixel.Sprite{} + for _, name := range EmojiNames { + rows := append([]string(nil), faceBase...) + for i, over := range features[name] { + row := []byte(rows[i]) + for j := range over { + if over[j] != '.' { + row[j] = over[j] + } + } + rows[i] = string(row) + } + out[name] = pixel.MustSprite(strings.Join(rows, "\n")) + } + return out +}() + +type EmojiOptions struct { + Scale int +} + +// Emoji draws one of sixteen hand-placed faces by name. +func Emoji(c *ui.Context, name string, o EmojiOptions) *ui.Element { + sp, ok := emoji[name] + if !ok { + panic(fmt.Sprintf("display: no emoji %q", name)) + } + return Icon(c, sp, IconOptions{Scale: o.Scale, Label: name}) +} diff --git a/display/figure.go b/display/figure.go new file mode 100644 index 0000000..9674119 --- /dev/null +++ b/display/figure.go @@ -0,0 +1,21 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FigureOptions struct { + // Slot inks the caption; zero means Faint. + Slot pixel.Slot +} + +// Figure shows content over a centered caption. +func Figure(c *ui.Context, caption string, o FigureOptions, content func()) *ui.Element { + k := pixel.K(c) + return ui.Column(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(caption).Children(func() { + content() + pixel.Text(c, caption, pixel.TextOptions{Slot: slotOr(o.Slot, pixel.Faint)}) + }) +} diff --git a/display/flag.go b/display/flag.go new file mode 100644 index 0000000..a43d8c7 --- /dev/null +++ b/display/flag.go @@ -0,0 +1,35 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FlagOptions struct { + // Slot is the cloth; zero means Bad. + Slot pixel.Slot + Scale int + Label string +} + +// Flag waves on its pole, stepped at 6 fps. +func Flag(c *ui.Context, o FlagOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + cloth := slotOr(o.Slot, pixel.Bad) + return art(c, k, o.Label, 20*sc, 26*sc, func(g pixel.Canvas) { + step := g.Step(6) + g.Rect(sc, 2*sc, sc, 24*sc, pixel.Edge) + g.Rect(0, 0, 3*sc, 2*sc, pixel.Warn) + for col := range 16 { + dy := [4]int{0, 1, 1, 0}[(step+col/4)%4] + x, y := (2+col)*sc, (3+dy)*sc + g.Rect(x, y, sc, 10*sc, cloth) + if (step+col/4)%4 == 2 { + g.Rect(x, y, sc, 10*sc, pixel.Shade) + g.Veil(x, y, sc, 10*sc, cloth, 8) + } + g.Rect(x, y, sc, sc, pixel.Lit) + } + }) +} diff --git a/display/gem.go b/display/gem.go new file mode 100644 index 0000000..38d739b --- /dev/null +++ b/display/gem.go @@ -0,0 +1,74 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Rarity uint8 + +const ( + Common Rarity = iota + Uncommon + Rare + Epic + Legendary +) + +func (r Rarity) slot() pixel.Slot { + if r > Legendary { + panic(fmt.Sprintf("display: unknown Rarity %d", r)) + } + return [...]pixel.Slot{pixel.Faint, pixel.Good, pixel.Info, pixel.Magic, pixel.Warn}[r] +} + +func (r Rarity) name(k pixel.Kit) string { + return k.Msg([...]string{"common", "uncommon", "rare", "epic", "legendary"}[r]) +} + +var ( + gem = pixel.MustSprite(` + ...eeeee... + ..e#l#l#e.. + .e##l#l##e. + eeeeeeeeeee + e#ll###ss#e + .e#l###s#e. + ..e#l#s#e.. + ...e#s#e... + ....e#e.... + .....e..... + `) + glint = pixel.MustSprite(` + ..#.. + ..#.. + ##.## + ..#.. + ..#.. + + ..... + ..#.. + .#.#. + ..#.. + ..... + `) +) + +type GemOptions struct { + Scale int +} + +// Gem is a cut gem by rarity, sparkling in steps. +func Gem(c *ui.Context, r Rarity, o GemOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + ink := r.slot() + return art(c, k, r.name(k), (gem.W+3)*sc, (gem.H+2)*sc, func(g pixel.Canvas) { + g.Sprite(0, 2*sc, gem, 0, ink, sc) + if f := g.Step(4) % 4; f < 2 { + g.Sprite(gem.W*sc-2*sc, 0, glint, f, pixel.Lit, sc) + } + }) +} diff --git a/display/icon.go b/display/icon.go new file mode 100644 index 0000000..bbe9d8f --- /dev/null +++ b/display/icon.go @@ -0,0 +1,20 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type IconOptions struct { + Slot pixel.Slot + Scale int + // Label names the icon; empty hides it. + Label string +} + +// Icon draws a sprite at ×1–×4 in an ink slot. +func Icon(c *ui.Context, sp *pixel.Sprite, o IconOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + return art(c, k, o.Label, sp.W*sc, sp.H*sc, func(g pixel.Canvas) { g.Sprite(0, 0, sp, 0, o.Slot, sc) }) +} diff --git a/display/internal_test.go b/display/internal_test.go new file mode 100644 index 0000000..7678b7f --- /dev/null +++ b/display/internal_test.go @@ -0,0 +1,90 @@ +package display + +import ( + "image" + "image/color" + "testing" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func fill(w, h int, c color.Color) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, c) + } + } + return img +} + +func TestReduce(t *testing.T) { + pal := pixel.SpringDay + a := pal.Colors[pixel.Accent] + for _, s := range reduce(fill(8, 8, color.RGBA{a.R, a.G, a.B, 255}), 4, 4, pal) { + if s != int8(pixel.Accent) { + t.Fatalf("a flat Accent image gave slot %d", s) + } + } + for _, s := range reduce(fill(4, 4, color.RGBA{}), 4, 4, pal) { + if s != -1 { + t.Fatal("a clear image drew") + } + } + i, b := pal.Colors[pixel.Info], pal.Colors[pixel.Back] + mid := color.RGBA{uint8((int(i.R) + int(b.R)) / 2), uint8((int(i.G) + int(b.G)) / 2), uint8((int(i.B) + int(b.B)) / 2), 255} + seen := map[int8]int{} + for _, s := range reduce(fill(16, 16, mid), 16, 16, pal) { + seen[s]++ + } + if len(seen) < 2 { + t.Fatalf("a midtone did not dither: %v", seen) + } +} + +func TestSpanCoversSource(t *testing.T) { + for _, tc := range [][2]int{{96, 48}, {10, 30}, {7, 7}} { + size, n := tc[0], tc[1] + next := 0 + for i := range n { + a, b := span(0, size, i, n) + if a > next || b <= a || b > size+1 { + t.Fatalf("span %d of %d over %d is %d..%d after %d", i, n, size, a, b, next) + } + next = max(next, b) + } + if next < size { + t.Fatalf("spans over %d stop at %d", size, next) + } + } +} + +func TestAroundWalksTheEdgeOnce(t *testing.T) { + w, h := 7, 5 + seen := map[[2]int]bool{} + for d := range 2*(w+h) - 4 { + x, y := around(w, h, d) + if seen[[2]int{x, y}] || (x != 0 && x != w-1 && y != 0 && y != h-1) { + t.Fatalf("step %d at %d,%d", d, x, y) + } + seen[[2]int{x, y}] = true + } +} + +func TestExtremesAndNames(t *testing.T) { + if d, l := extremes(pixel.SpringDay); d != pixel.Ink || l != pixel.AccentInk { + t.Fatalf("Spring Day extremes %v %v", d, l) + } + if d, l := extremes(pixel.SpringNight); d == l { + t.Fatal("night extremes collapsed") + } + if len(emoji) != 16 || len(EmojiNames) != 16 { + t.Fatal("emoji count") + } + defer func() { + if recover() == nil { + t.Fatal("unknown rarity did not panic") + } + }() + Rarity(9).slot() +} diff --git a/display/keyvalue.go b/display/keyvalue.go new file mode 100644 index 0000000..b6240a2 --- /dev/null +++ b/display/keyvalue.go @@ -0,0 +1,24 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type KeyValueOptions struct { + // Slot inks the value; zero means Ink. + Slot pixel.Slot +} + +// KeyValue joins key and value with a dotted leader. +func KeyValue(c *ui.Context, key, value string, o KeyValueOptions) *ui.Element { + k := pixel.K(c) + kw, vw := pixel.Measure(key), pixel.Measure(value) + return ui.Box(c).FillWidth().MinWidth(k.D(kw + vw + 8)).Height(k.D(pixel.LineHeight)).Role(ui.RoleText).Label(key + " " + value).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Text(0, 0, key, pixel.Faint, 1) + g.Checker(kw+3, pixel.LineHeight-5, g.W-kw-vw-6, 1, pixel.Faint) + g.Text(g.W-vw, 0, value, o.Slot, 1) + }) +} diff --git a/display/medal.go b/display/medal.go new file mode 100644 index 0000000..6619b30 --- /dev/null +++ b/display/medal.go @@ -0,0 +1,61 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Rank uint8 + +const ( + Gold Rank = iota + Silver + Bronze +) + +var ribbon = pixel.MustSprite(` + aaa.....ccc + aaaa...cccc + .aaaa.cccc. + ..aaaccccc. + ...aaccc... + ....acc.... +`) + +type MedalOptions struct { + Scale int +} + +// Medal is a ribboned medal showing its rank. +func Medal(c *ui.Context, r Rank, o MedalOptions) *ui.Element { + if r > Bronze { + panic(fmt.Sprintf("display: unknown Rank %d", r)) + } + k, sc := pixel.K(c), scale(o.Scale) + name := k.Msg([...]string{"gold", "silver", "bronze"}[r]) + return art(c, k, name, 11*sc, 18*sc, func(g pixel.Canvas) { + g.Sprite(0, 0, ribbon, 0, pixel.Ink, sc) + g.Disc(5*sc, 12*sc, 5*sc, pixel.Edge) + g.Disc(5*sc, 12*sc, 5*sc-1, pixel.Shade) + metal(g, r, 5*sc, 12*sc, 5*sc-2) + g.Micro(5*sc-1, 12*sc-2, itoa(int(r)+1), pixel.Edge, 1) + }) +} + +// metal fills a disc in the rank's colors. +func metal(g pixel.Canvas, r Rank, cx, cy, rad int) { + switch r { + case Gold: + g.Disc(cx, cy, rad, pixel.Warn) + case Silver: + g.Disc(cx, cy, rad, pixel.Face) + case Bronze: + g.Disc(cx, cy, rad, pixel.Warn) + g.Veil(cx-rad, cy-rad, 2*rad+1, 2*rad+1, pixel.Bad, 8) + g.Circle(cx, cy, rad, pixel.Shade) + } + g.Dot(cx-rad/2, cy-rad/2, pixel.Lit) +} diff --git a/display/picture.go b/display/picture.go new file mode 100644 index 0000000..db27cdd --- /dev/null +++ b/display/picture.go @@ -0,0 +1,113 @@ +package display + +import ( + "image" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PictureOptions struct { + // W×H is the size in dots; zero takes the image's. + W, H int + Label string +} + +type pictureKey struct{} + +type pictureCache struct { + sum uint64 + pal *pixel.Palette + w, h int + dots []int8 +} + +// Picture reduces img to the palette with Bayer dither. +func Picture(c *ui.Context, img image.Image, o PictureOptions) *ui.Element { + k := pixel.K(c) + b := img.Bounds() + w, h := orDef("Picture W", o.W, b.Dx()), orDef("Picture H", o.H, b.Dy()) + e := art(c, k, o.Label, w, h, nil) + pc := ui.Local(e, pictureKey{}, func() pictureCache { return pictureCache{} }) + if sum := fingerprint(img); pc.dots == nil || pc.sum != sum || pc.pal != k.Palette() || pc.w != w || pc.h != h { + *pc = pictureCache{sum, k.Palette(), w, h, reduce(img, w, h, k.Palette())} + } + dots := pc.dots + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for y := range h { + for x := 0; x < w; { + s, end := dots[y*w+x], x+1 + for end < w && dots[y*w+end] == s { + end++ + } + if s >= 0 { + g.Rect(x, y, end-x, 1, pixel.Slot(s)) + } + x = end + } + } + }) +} + +// reduce maps img to w×h slots; -1 marks clear dots. +func reduce(img image.Image, w, h int, pal *pixel.Palette) []int8 { + b := img.Bounds() + out := make([]int8, w*h) + for y := range h { + y0, y1 := span(b.Min.Y, b.Dy(), y, h) + for x := range w { + x0, x1 := span(b.Min.X, b.Dx(), x, w) + var sum [4]uint32 + n := uint32(0) + for sy := y0; sy < y1; sy++ { + for sx := x0; sx < x1; sx++ { + r, g, bl, a := img.At(sx, sy).RGBA() + sum[0], sum[1], sum[2], sum[3] = sum[0]+r>>8, sum[1]+g>>8, sum[2]+bl>>8, sum[3]+a>>8 + n++ + } + } + if sum[3]/n < 128 { + out[y*w+x] = -1 + continue + } + nudge := (float64(pixel.Bayer(x, y))+0.5)/16 - 0.5 + out[y*w+x] = int8(nearest(pal, [3]float64{ + float64(sum[0]/n) + nudge*48, float64(sum[1]/n) + nudge*48, float64(sum[2]/n) + nudge*48, + })) + } + } + return out +} + +// fingerprint mixes img's bounds and pixels, to notice edits. +func fingerprint(img image.Image) uint64 { + b := img.Bounds() + s := uint64(b.Min.X)<<48 ^ uint64(b.Min.Y)<<32 ^ uint64(b.Dx())<<16 ^ uint64(b.Dy()) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, a := img.At(x, y).RGBA() + s = (s ^ uint64(r>>8)<<24 ^ uint64(g>>8)<<16 ^ uint64(bl>>8)<<8 ^ uint64(a>>8)) * 1099511628211 + } + } + return s +} + +// span is the source range under cell i of n. +func span(min, size, i, n int) (int, int) { + a, b := min+i*size/n, min+(i+1)*size/n + return a, max(b, a+1) +} + +// nearest is the slot closest to rgb. +func nearest(pal *pixel.Palette, rgb [3]float64) pixel.Slot { + best, dist := pixel.Ink, -1.0 + for s, c := range pal.Colors { + dr, dg, db := rgb[0]-float64(c.R), rgb[1]-float64(c.G), rgb[2]-float64(c.B) + if d := dr*dr + dg*dg + db*db; dist < 0 || d < dist { + best, dist = pixel.Slot(s), d + } + } + return best +} diff --git a/display/placeholder.go b/display/placeholder.go new file mode 100644 index 0000000..5931e3f --- /dev/null +++ b/display/placeholder.go @@ -0,0 +1,36 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PlaceholderOptions struct { + // W×H is zero for 64×48 dots. + W, H int +} + +// Placeholder is a dithered box with a cross and size. +func Placeholder(c *ui.Context, o PlaceholderOptions) *ui.Element { + k := pixel.K(c) + w, h := orDef("Placeholder W", o.W, 64), orDef("Placeholder H", o.H, 48) + size := fmt.Sprintf("%d×%d", w, h) + return art(c, k, size, w, h, func(g pixel.Canvas) { + if w < 3 || h < 3 { + g.Dither(0, 0, w, h, pixel.Face, pixel.Shade, 4) + return + } + g.Frame(0, 0, w, h, pixel.Flat, pixel.Face) + g.Dither(1, 1, w-2, h-2, pixel.Face, pixel.Shade, 4) + g.Line(1, 1, w-2, h-2, pixel.Shade) + g.Line(w-2, 1, 1, h-2, pixel.Shade) + tw := pixel.Measure(size) + if tw+4 <= w && pixel.LineHeight <= h { + g.Rect((w-tw)/2-2, (h-pixel.LineHeight)/2, tw+4, pixel.LineHeight, pixel.Face) + g.Text((w-tw)/2, (h-pixel.LineHeight)/2, size, pixel.Faint, 1) + } + }) +} diff --git a/display/portrait.go b/display/portrait.go new file mode 100644 index 0000000..8a92855 --- /dev/null +++ b/display/portrait.go @@ -0,0 +1,31 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PortraitOptions struct { + // Size is the picture's side in dots; zero means 48. + Size int +} + +// Portrait frames a character's picture over a nameplate. +func Portrait(c *ui.Context, name string, o PortraitOptions, picture func()) *ui.Element { + k := pixel.K(c) + size := orDef("Portrait Size", o.Size, 48) + return ui.Column(c).Width(k.D(size + 6)).Role(ui.RoleGroup).Label(name).Children(func() { + ui.Box(c).Size(k.D(size+6), k.D(size+6)).Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Double, pixel.Well) + }).Children(func() { + ui.Box(c).Size(k.D(size), k.D(size)).Clip().Children(picture) + }) + ui.Box(c).Size(k.D(size+6), k.D(pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Face) + g.Clip(1, 0, g.W-2, g.H, func() { g.Text((g.W-pixel.Measure(name))/2, 1, name, pixel.Ink, 1) }) + }) + }) +} diff --git a/display/qrcode.go b/display/qrcode.go new file mode 100644 index 0000000..f7bd2cb --- /dev/null +++ b/display/qrcode.go @@ -0,0 +1,75 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + "rsc.io/qr" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type QRCodeOptions struct { + // Scale is dots per module; zero means 2. + Scale int +} + +type qrKey struct{} + +type qrCache struct { + text string + code *qr.Code +} + +// QRCode encodes s at level M, with a quiet zone. +func QRCode(c *ui.Context, s string, o QRCodeOptions) *ui.Element { + k := pixel.K(c) + sc := orDef("QRCode Scale", o.Scale, 2) + e := ui.Box(c) + qc := ui.Local(e, qrKey{}, func() qrCache { return qrCache{} }) + if qc.code == nil || qc.text != s { + code, err := qr.Encode(s, qr.M) + if err != nil { + panic(fmt.Sprintf("display: QRCode %q: %v", s, err)) + } + *qc = qrCache{s, code} + } + code := qc.code + n := (code.Size + 8) * sc + dark, light := extremes(k.Palette()) + return e.Size(k.D(n), k.D(n)).Role(ui.RoleImage).Label(s).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, n, n, light) + for y := range code.Size { + for x := 0; x < code.Size; { + if !code.Black(x, y) { + x++ + continue + } + end := x + 1 + for end < code.Size && code.Black(end, y) { + end++ + } + g.Rect((4+x)*sc, (4+y)*sc, (end-x)*sc, sc, dark) + x = end + } + } + }) +} + +// extremes are the darkest and lightest slots, for scanners. +func extremes(pal *pixel.Palette) (dark, light pixel.Slot) { + lum := func(s pixel.Slot) int { + c := pal.Colors[s] + return 299*int(c.R) + 587*int(c.G) + 114*int(c.B) + } + for s := range pixel.Slot(pixel.Slots) { + if lum(s) < lum(dark) { + dark = s + } + if lum(s) > lum(light) { + light = s + } + } + return dark, light +} diff --git a/display/rarityframe.go b/display/rarityframe.go new file mode 100644 index 0000000..618aaad --- /dev/null +++ b/display/rarityframe.go @@ -0,0 +1,64 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RarityFrameOptions struct { + // Padding is inside the border; zero means 4. + Padding int +} + +// RarityFrame borders children by rarity; legendary carries a glint. +func RarityFrame(c *ui.Context, r Rarity, o RarityFrameOptions, children func()) *ui.Element { + k := pixel.K(c) + ink := r.slot() + pad := 3 + orDef("RarityFrame Padding", o.Padding, 4) + return ui.Column(c).Padding(k.D(pad)).Role(ui.RoleGroup).Label(r.name(k)).Draw(func(p *ui.Painter, r2 ui.Rect) { + g := k.Canvas(p, r2) + if g.W < 6 || g.H < 6 { + return + } + g.Frame(0, 0, g.W, g.H, pixel.Flat, ink) + g.Rect(3, 3, g.W-6, g.H-6, pixel.Face) + g.Rect(2, 2, g.W-4, 1, pixel.Edge) + g.Rect(2, g.H-3, g.W-4, 1, pixel.Edge) + g.Rect(2, 2, 1, g.H-4, pixel.Edge) + g.Rect(g.W-3, 2, 1, g.H-4, pixel.Edge) + if r >= Epic { + for _, at := range [][2]int{{0, 0}, {g.W - 2, 0}, {0, g.H - 2}, {g.W - 2, g.H - 2}} { + g.Rect(at[0], at[1], 2, 2, pixel.Lit) + } + } + }).DrawOver(func(p *ui.Painter, r2 ui.Rect) { + if r != Legendary { + return + } + g := k.Canvas(p, r2) + if g.W < 6 || g.H < 6 { + return + } + per := 2*(g.W+g.H) - 4 + at := g.Step(20) * 2 % per + for i := range 6 { + x, y := around(g.W, g.H, (at+i)%per) + g.Dot(x, y, pixel.Lit) + } + }).Children(children) +} + +// around is the edge dot d steps clockwise from 0,0. +func around(w, h, d int) (int, int) { + switch { + case d < w: + return d, 0 + case d < w+h-1: + return w - 1, d - w + 1 + case d < 2*w+h-2: + return w - 1 - (d - w - h + 2), h - 1 + default: + return 0, h - 1 - (d - 2*w - h + 3) + } +} diff --git a/display/ribbon.go b/display/ribbon.go new file mode 100644 index 0000000..755e54f --- /dev/null +++ b/display/ribbon.go @@ -0,0 +1,48 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RibbonOptions struct { + // Slot is the band; zero means Accent. + Slot pixel.Slot +} + +const tail = 12 + +// Ribbon is a title banner with notched, folded tails. +func Ribbon(c *ui.Context, title string, o RibbonOptions) *ui.Element { + k := pixel.K(c) + band := slotOr(o.Slot, pixel.Accent) + tw := pixel.Measure(title) + w, h := tw+2*tail+8, pixel.LineHeight+4 + return ui.Box(c).Size(k.D(w), k.D(h+4)).Role(ui.RoleText).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for j := range h { + cut := h/2 - abs(j-h/2) + for _, x0 := range []int{cut, w - tail} { + n := tail - cut + g.HLine(x0, 4+j, n, band) + g.Veil(x0, 4+j, n, 1, pixel.Shade, 8) + if j == 0 || j == h-1 { + g.HLine(x0, 4+j, n, pixel.Edge) + } + } + } + g.Rect(tail-3, h+1, 3, 3, pixel.Edge) + g.Rect(w-tail, h+1, 3, 3, pixel.Edge) + g.Frame(tail-3, 0, w-2*tail+6, h, pixel.Flat, band) + g.HLine(tail-2, 1, w-2*tail+4, pixel.AccentLit) + g.Text((w-tw)/2, 2, title, pixel.AccentInk, 1) + }) +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/display/signpost.go b/display/signpost.go new file mode 100644 index 0000000..850d88b --- /dev/null +++ b/display/signpost.go @@ -0,0 +1,93 @@ +package display + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SignpostOptions struct { + Disabled bool +} + +type SignpostResult struct { + Element *ui.Element + // Clicked is the board clicked this frame, or -1. + Clicked int +} + +// Signpost is a post with one to three arrow boards. +func Signpost(c *ui.Context, boards []string, o SignpostOptions) SignpostResult { + if len(boards) < 1 || len(boards) > 3 { + panic(fmt.Sprintf("display: Signpost needs 1–3 boards, got %d", len(boards))) + } + k := pixel.K(c) + w := 0 + for _, b := range boards { + w = max(w, pixel.Measure(b)) + } + w += 18 + res := SignpostResult{Clicked: -1} + res.Element = ui.Column(c).Width(k.D(w+8)).Gap(k.D(3)).AlignItems(ui.Center).Padding(0, 0, k.D(20), 0).Role(ui.RoleGroup).FocusGroup(ui.Vertical).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(g.W/2-2, 2, 4, g.H-2, pixel.Shade) + g.VLine(g.W/2-2, 2, g.H-2, pixel.Edge) + g.VLine(g.W/2+1, 2, g.H-2, pixel.Edge) + }).Children(func() { + for i, b := range boards { + if board(c, k, b, w, i%2 == 1, o.Disabled) { + res.Clicked = i + } + } + }) + if res.Clicked >= 0 { + slog.Debug("8bit: signpost", "board", boards[res.Clicked]) + } + return res +} + +// board is one wooden arrow, pointing left if asked. +func board(c *ui.Context, k pixel.Kit, label string, w int, left, disabled bool) bool { + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(label).Disabled(disabled) }) + h := pixel.LineHeight + 2 + shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + dy := 0 + if st.Press { + dy = 1 + } + for j := range h - dy { + cut := abs(j - (h-1)/2) + x0, x1 := 0, w-cut + if left { + x0, x1 = cut, w + } + fill := pixel.Warn + if j == 0 || j == h-1-dy { + fill = pixel.Edge + } + g.HLine(x0, j+dy, x1-x0, fill) + g.Dot(x0, j+dy, pixel.Edge) + g.Dot(x1-1, j+dy, pixel.Edge) + } + g.Veil(1, 1+dy, w-2, h-3, pixel.Shade, 4) + tx := 6 + if left { + tx = w - 6 - pixel.Measure(label) + } + g.Text(tx, 1+dy, label, pixel.Ink, 1) + if st.Hover { + g.Veil(1, 1+dy, w-2, h-3, pixel.Lit, 4) + } + if st.Disabled { + g.Checker(0, 0, w, h, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, w, h) + } + }) + return base.Clicked() +} diff --git a/display/sprite.go b/display/sprite.go new file mode 100644 index 0000000..4625a14 --- /dev/null +++ b/display/sprite.go @@ -0,0 +1,32 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SpriteOptions struct { + Slot pixel.Slot + Scale int + // FPS plays the frames; zero shows the first. + FPS int + Mirror bool + Label string +} + +// Sprite plays a multi-slot sprite, mirrored when asked. +func Sprite(c *ui.Context, sp *pixel.Sprite, o SpriteOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + fps := orDef("Sprite FPS", o.FPS, 0) + if o.Mirror { + sp = sp.Mirror() + } + return art(c, k, o.Label, sp.W*sc, sp.H*sc, func(g pixel.Canvas) { + frame := 0 + if fps > 0 { + frame = g.Step(fps) + } + g.Sprite(0, 0, sp, frame, o.Slot, sc) + }) +} diff --git a/display/stamp.go b/display/stamp.go new file mode 100644 index 0000000..ca38866 --- /dev/null +++ b/display/stamp.go @@ -0,0 +1,49 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type StampOptions struct { + // Slot is the ink; zero means Bad. + Slot pixel.Slot + Seed int +} + +// Stamp is a rubber-stamp mark whose border wears unevenly. +func Stamp(c *ui.Context, s string, o StampOptions) *ui.Element { + k := pixel.K(c) + ink := slotOr(o.Slot, pixel.Bad) + w, h := pixel.Measure(s)+12, pixel.LineHeight+8 + return art(c, k, s, w, h, func(g pixel.Canvas) { + ring := func(in int) { + for i := in; i < w-in; i++ { + worn(g, o.Seed, i, in, ink) + worn(g, o.Seed, i, h-1-in, ink) + } + for j := in + 1; j < h-1-in; j++ { + worn(g, o.Seed, in, j, ink) + worn(g, o.Seed, w-1-in, j, ink) + } + } + ring(0) + ring(2) + g.Text(6, 4, s, ink, 1) + }) +} + +// worn inks a border dot unless seeded wear skips it. +func worn(g pixel.Canvas, seed, x, y int, ink pixel.Slot) { + if hash(seed, x, y)%7 != 0 { + g.Dot(x, y, ink) + } +} + +func hash(seed, x, y int) uint32 { + n := uint32(seed*73856093 ^ x*19349663 ^ y*83492791) + n ^= n >> 13 + n *= 0x5bd1e995 + return n ^ n>>15 +} diff --git a/display/stat.go b/display/stat.go new file mode 100644 index 0000000..0b7de99 --- /dev/null +++ b/display/stat.go @@ -0,0 +1,46 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var ( + rise = pixel.MustSprite(` + ..#.. + .###. + ##### + `) + fall = pixel.MustSprite(` + ##### + .###. + ..#.. + `) +) + +type StatOptions struct { + // Delta shows under the value, ▲ in Good. + Delta string + // Down turns the delta to ▼ in Bad. + Down bool +} + +// Stat is a big value with a label and delta. +func Stat(c *ui.Context, label, value string, o StatOptions) *ui.Element { + k := pixel.K(c) + return ui.Column(c).Gap(k.D(1)).Role(ui.RoleGroup).Label(label).Children(func() { + pixel.Text(c, label, pixel.TextOptions{Slot: pixel.Faint}) + pixel.Text(c, value, pixel.TextOptions{Scale: 2}) + if o.Delta != "" { + sp, ink := rise, pixel.Good + if o.Down { + sp, ink = fall, pixel.Bad + } + ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() { + Icon(c, sp, IconOptions{Slot: ink}) + pixel.Text(c, o.Delta, pixel.TextOptions{Slot: ink}) + }) + } + }) +} diff --git a/display/statusdot.go b/display/statusdot.go new file mode 100644 index 0000000..e0f8c3b --- /dev/null +++ b/display/statusdot.go @@ -0,0 +1,48 @@ +package display + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Status uint8 + +const ( + Online Status = iota + Away + Busy + Offline +) + +var ( + statusSlots = [...]pixel.Slot{Online: pixel.Good, Away: pixel.Warn, Busy: pixel.Bad, Offline: pixel.Faint} + statusKeys = [...]string{Online: "online", Away: "away", Busy: "busy", Offline: "offline"} +) + +type StatusDotOptions struct { + // Live makes an online dot blink. + Live bool + Label string +} + +// StatusDot is a 7-dot status light. +func StatusDot(c *ui.Context, s Status, o StatusDotOptions) *ui.Element { + if int(s) >= len(statusSlots) { + panic(fmt.Sprintf("display: unknown Status %d", s)) + } + k := pixel.K(c) + label := o.Label + if label == "" { + label = k.Msg(statusKeys[s]) + } + return art(c, k, label, 7, 7, func(g pixel.Canvas) { + g.Disc(3, 3, 3, pixel.Edge) + if !(o.Live && s == Online && g.Step(2)%2 == 1) { + g.Disc(3, 3, 2, statusSlots[s]) + g.Dot(2, 2, pixel.Lit) + } + }) +} diff --git a/display/swatch.go b/display/swatch.go new file mode 100644 index 0000000..f916e85 --- /dev/null +++ b/display/swatch.go @@ -0,0 +1,28 @@ +package display + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SwatchOptions struct { + // Columns is zero for 4. + Columns int +} + +// Swatch shows the active palette's sixteen slots with names. +func Swatch(c *ui.Context, o SwatchOptions) *ui.Element { + k := pixel.K(c) + cols := orDef("Swatch Columns", o.Columns, 4) + cell := 14 + pixel.Measure("AccentLit") + return ui.Row(c).Wrap().Width(k.D(cols * (cell + 4))).Gap(k.D(4)).Role(ui.RoleList).Label(k.Palette().Name).Children(func() { + for s := range pixel.Slot(pixel.Slots) { + ui.Box(c).Size(k.D(cell), k.D(pixel.LineHeight)).Role(ui.RoleListItem).Label(s.String()).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 2, 12, 12, pixel.Flat, s) + g.Text(14, 0, s.String(), pixel.Ink, 1) + }) + } + }) +} diff --git a/display/tag.go b/display/tag.go new file mode 100644 index 0000000..be1469f --- /dev/null +++ b/display/tag.go @@ -0,0 +1,76 @@ +package display + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var cross = pixel.MustSprite(` + #...# + .#.#. + ..#.. + .#.#. + #...# +`) + +type TagOptions struct { + Icon *pixel.Sprite + Removable bool + // Slot is the chip fill; zero means Face. + Slot pixel.Slot + Disabled bool +} + +type TagResult struct { + Element *ui.Element + Removed bool +} + +// Tag is a chip with text, icon and remove box. +func Tag(c *ui.Context, label string, o TagOptions) TagResult { + k := pixel.K(c) + fill := slotOr(o.Slot, pixel.Face) + res := TagResult{} + res.Element = ui.Row(c).Height(k.D(pixel.LineHeight+2)).Padding(0, k.D(4)).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if g.W >= 3 && g.H >= 3 { + g.Frame(0, 0, g.W, g.H, pixel.Notched, fill) + } + }).DrawOver(func(p *ui.Painter, r ui.Rect) { + if o.Disabled { + g := k.Canvas(p, r) + g.Checker(0, 0, g.W, g.H, fill) + } + }).Children(func() { + if o.Icon != nil { + Icon(c, o.Icon, IconOptions{}) + } + pixel.Text(c, label, pixel.TextOptions{}) + if o.Removable { + res.Removed = removeBox(c, k, k.Msg("remove")+" "+label, o.Disabled) + } + }) + if res.Removed { + slog.Debug("8bit: tag removed", "label", label) + } + return res +} + +// removeBox is the × that removes a tag. +func removeBox(c *ui.Context, k pixel.Kit, name string, disabled bool) bool { + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name).Disabled(disabled) }) + shell.Size(k.D(9), k.D(9)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if st.Hover || st.Press { + g.Rect(0, 0, g.W, g.H, pixel.Shade) + } + g.Sprite(2, 2, cross, 0, pixel.Ink, 1) + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + return base.Clicked() +} diff --git a/display/trophy.go b/display/trophy.go new file mode 100644 index 0000000..404f824 --- /dev/null +++ b/display/trophy.go @@ -0,0 +1,51 @@ +package display + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var cup = pixel.MustSprite(` + ..eeeeeeeeeee.. + eeeyyyyyyyyyeee + e.eyllyyyyyye.e + e.eylyyyyyyye.e + .eeyyyyyyyyyee. + ...eyyyyyyye... + ....eyyyyye.... + .....eyyye..... + ......eye...... + ......eye...... + .....eyyye..... + ...eesssssee... + ...esssssssse.. +`) + +type TrophyOptions struct { + Scale int +} + +// Trophy is a gold cup over an engraved plaque. +func Trophy(c *ui.Context, plaque string, o TrophyOptions) *ui.Element { + k, sc := pixel.K(c), scale(o.Scale) + up := strings.ToUpper(plaque) + micro := pixel.MicroOK(up) + tw, th := pixel.Measure(plaque), pixel.LineHeight + if micro { + tw, th = pixel.MicroWidth(up), pixel.MicroHeight + } + pw, ph := tw+6, th+4 + w := max(cup.W*sc, pw) + return art(c, k, plaque, w, cup.H*sc+1+ph, func(g pixel.Canvas) { + g.Sprite((w-cup.W*sc)/2, 0, cup, 0, pixel.Ink, sc) + g.Frame((w-pw)/2, cup.H*sc+1, pw, ph, pixel.Raised, pixel.Shade) + if micro { + g.Micro((w-pw)/2+3, cup.H*sc+3, up, pixel.Ink, 1) + } else { + g.Text((w-pw)/2+3, cup.H*sc+3, plaque, pixel.Ink, 1) + } + }) +} diff --git a/gallery/demos_display.go b/gallery/demos_display.go new file mode 100644 index 0000000..9702110 --- /dev/null +++ b/gallery/demos_display.go @@ -0,0 +1,272 @@ +package gallery + +import ( + "fmt" + "image" + "image/color" + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var displayEntries = []Entry{ + {41, "Icon", "图标", "display", "A sprite in one ink slot at ×1 to ×4, named for screen readers or hidden.", demoIcon}, + {42, "Sprite", "精灵", "display", "A multi-slot sprite that plays its frames and can face the other way.", demoSprite}, + {43, "Picture", "像素图", "display", "Any image reduced to the palette with Bayer dither. Press P: it recolors.", demoPicture}, + {44, "Avatar", "头像", "display", "A face built from a name: skin, hair, eyes, mouth and background.", demoAvatar}, + {45, "AvatarGroup", "头像组", "display", "Overlapping faces and a chip for the rest.", demoAvatarGroup}, + {46, "Badge", "徽标", "display", "A count or a word in a notched pill; counts stop at 99+.", demoBadge}, + {47, "Tag", "标签", "display", "A chip with text, an icon and a remove box.", demoTag}, + {48, "StatusDot", "状态点", "display", "Online, away, busy or offline; a live online dot blinks.", demoStatusDot}, + {49, "Medal", "奖牌", "display", "Gold, silver and bronze on ribbons, rank on the face.", demoMedal}, + {50, "Trophy", "奖杯", "display", "A gold cup over an engraved plaque.", demoTrophy}, + {51, "Stat", "数据", "display", "A big value, its label and a change up or down.", demoStat}, + {52, "KeyValue", "键值", "display", "A label and a value joined by a dotted leader.", demoKeyValue}, + {53, "Portrait", "立绘框", "display", "A character in a frame over a nameplate.", demoPortrait}, + {54, "Ribbon", "绶带", "display", "A title banner with folded, notched tails.", demoRibbon}, + {55, "Stamp", "印章", "display", "A rubber stamp whose border wore unevenly.", demoStamp}, + {56, "Emoji", "表情", "display", "Sixteen faces placed dot by dot.", demoEmoji}, + {57, "Flag", "旗帜", "display", "A flag on a pole, waving in steps.", demoFlag}, + {58, "Gem", "宝石", "display", "A cut gem for each rarity, with a stepped sparkle.", demoGem}, + {59, "RarityFrame", "稀有度框", "display", "A border by rarity; a legendary one carries a running glint.", demoRarityFrame}, + {60, "Bubble", "气泡", "display", "A speech bubble with its tail on any side.", demoBubble}, + {61, "Swatch", "色板", "display", "The sixteen slots of the palette in use, by name.", demoSwatch}, + {62, "Placeholder", "占位图", "display", "A dithered box with a cross and its size, for art to come.", demoPlaceholder}, + {63, "Figure", "插图", "display", "Content over a caption in Faint.", demoFigure}, + {64, "QRCode", "二维码", "display", "A scannable QR code in the darkest and lightest slots.", demoQRCode}, + {65, "Signpost", "路牌", "display", "A post with up to three boards. Each board is a button.", demoSignpost}, +} + +// sunset is a smooth picture for Picture to reduce. +var sunset = func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 96, 64)) + for y := range 64 { + for x := range 96 { + t := float64(y) / 63 + c := color.RGBA{uint8(250 - 120*t), uint8(170 - 120*t), uint8(90 + 90*t), 255} + if dx, dy := float64(x-60), float64(y-34); dx*dx+dy*dy < 14*14 { + c = color.RGBA{255, 220, 120, 255} + } + if float64(y) > 44+6*math.Sin(float64(x)/9) { + c = color.RGBA{50, 40, 70, 255} + } + img.SetRGBA(x, y, c) + } + } + return img +}() + +func demoIcon(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.End).Children(func() { + for sc := 1; sc <= 4; sc++ { + display.Icon(c, icon.Heart, display.IconOptions{Slot: pixel.Bad, Scale: sc, Label: fmt.Sprintf("heart ×%d", sc)}) + } + }) +} + +func demoSprite(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(12)).Children(func() { + display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 2, Label: "slime"}) + display.Sprite(c, slime, display.SpriteOptions{Scale: 3, FPS: 2, Mirror: true, Label: "slime, mirrored"}) + }) +} + +func demoPicture(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.End).Children(func() { + display.Picture(c, sunset, display.PictureOptions{Label: "sunset"}) + display.Picture(c, sunset, display.PictureOptions{W: 48, H: 32, Label: "small sunset"}) + }) +} + +var party = []string{"Aria", "Brom", "Cass", "Dax", "Eve", "Finn", "Gus"} + +func demoAvatar(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).Children(func() { + for _, n := range party[:5] { + display.Avatar(c, n, display.AvatarOptions{Scale: 2}) + } + display.Avatar(c, "Sunny", display.AvatarOptions{Scale: 2, Picture: sunset}) + }) +} + +func demoAvatarGroup(c *ui.Context, log func(string)) { + display.AvatarGroup(c, party, display.AvatarGroupOptions{Scale: 2}) +} + +func demoBadge(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + for _, n := range []int{3, 42, 128} { + display.Badge(c, n, display.BadgeOptions{}) + } + display.Badge(c, 0, display.BadgeOptions{Text: "NEW", Slot: pixel.Good}) + display.Badge(c, 0, display.BadgeOptions{Text: "HOT", Slot: pixel.Warn}) + }) +} + +func demoTag(c *ui.Context, log func(string)) { + k := pixel.K(c) + tags := ui.Local(c.Root(), demoKey("tags"), func() []string { return []string{"fire", "ice", "thunder", "wind"} }) + ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Row(c).Wrap().Gap(k.D(4)).Children(func() { + for i, t := range *tags { + if display.Tag(c, t, display.TagOptions{Icon: icon.Sparkle, Removable: true}).Removed { + *tags = append((*tags)[:i:i], (*tags)[i+1:]...) + log("removed " + t) + } + } + display.Tag(c, "locked", display.TagOptions{Icon: icon.Lock, Removable: true, Disabled: true}) + }) + if button(c, "RESET") { + *tags = []string{"fire", "ice", "thunder", "wind"} + log("tags reset") + } + }) +} + +func demoStatusDot(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + for _, s := range []display.Status{display.Online, display.Away, display.Busy, display.Offline} { + ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + display.StatusDot(c, s, display.StatusDotOptions{Live: true}) + label(c, []string{"Player 1", "Player 2", "Player 3", "Player 4"}[s], pixel.Ink) + }) + } + }) +} + +func demoMedal(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(10)).Children(func() { + for _, r := range []display.Rank{display.Gold, display.Silver, display.Bronze} { + display.Medal(c, r, display.MedalOptions{Scale: 2}) + } + }) +} + +func demoTrophy(c *ui.Context, log func(string)) { + display.Trophy(c, "Champion 2026", display.TrophyOptions{Scale: 2}) +} + +func demoStat(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(24)).Children(func() { + display.Stat(c, "SCORE", "42,000", display.StatOptions{Delta: "12%"}) + display.Stat(c, "LIVES", "2", display.StatOptions{Delta: "1", Down: true}) + }) +} + +func demoKeyValue(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Width(k.D(160)).Gap(k.D(2)).Children(func() { + for _, kv := range [][2]string{{"STR", "18"}, {"DEX", "12"}, {"INT", "7"}, {"LUCK", "99"}} { + display.KeyValue(c, kv[0], kv[1], display.KeyValueOptions{}) + } + }) +} + +func demoPortrait(c *ui.Context, log func(string)) { + display.Portrait(c, "Sir Brom", display.PortraitOptions{Size: 48}, func() { + display.Avatar(c, "Brom", display.AvatarOptions{Scale: 3}) + }) +} + +func demoRibbon(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + display.Ribbon(c, "STAGE 1-1", display.RibbonOptions{}) + display.Ribbon(c, "BONUS ROUND", display.RibbonOptions{Slot: pixel.Info}) + }) +} + +func demoStamp(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(10)).Children(func() { + display.Stamp(c, "APPROVED", display.StampOptions{Slot: pixel.Good, Seed: 3}) + display.Stamp(c, "VOID", display.StampOptions{Seed: 9}) + }) +} + +func demoEmoji(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Wrap().Gap(k.D(6)).Width(k.D(240)).Children(func() { + for _, name := range display.EmojiNames { + display.Emoji(c, name, display.EmojiOptions{Scale: 2}) + } + }) +} + +func demoFlag(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(12)).Children(func() { + display.Flag(c, display.FlagOptions{Scale: 2, Label: "red flag"}) + display.Flag(c, display.FlagOptions{Slot: pixel.Info, Scale: 2, Label: "blue flag"}) + }) +} + +func demoGem(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).Children(func() { + for r := display.Common; r <= display.Legendary; r++ { + display.Gem(c, r, display.GemOptions{Scale: 2}) + } + }) +} + +func demoRarityFrame(c *ui.Context, log func(string)) { + k := pixel.K(c) + items := []string{"Stick", "Bow", "Rapier", "Dragon Lance", "Excalibur"} + ui.Row(c).Wrap().Gap(k.D(6)).Children(func() { + for r := display.Common; r <= display.Legendary; r++ { + display.RarityFrame(c, r, display.RarityFrameOptions{}, func() { label(c, items[r], pixel.Ink) }) + } + }) +} + +func demoBubble(c *ui.Context, log func(string)) { + k := pixel.K(c) + lines := []string{"Hey!", "Look up.", "Psst.", "Over here."} + ui.Row(c).Wrap().Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + for i, side := range []display.Side{display.Bottom, display.Top, display.Left, display.Right} { + display.Bubble(c, display.BubbleOptions{Tail: side}, func() { label(c, lines[i], pixel.Ink) }) + } + }) +} + +func demoSwatch(c *ui.Context, log func(string)) { + display.Swatch(c, display.SwatchOptions{}) +} + +func demoPlaceholder(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).Children(func() { + display.Placeholder(c, display.PlaceholderOptions{}) + display.Placeholder(c, display.PlaceholderOptions{W: 120, H: 40}) + }) +} + +func demoFigure(c *ui.Context, log func(string)) { + display.Figure(c, "Fig. 1 — a slime, at rest", display.FigureOptions{}, func() { + display.Sprite(c, slime, display.SpriteOptions{Scale: 4, Label: "slime"}) + }) +} + +func demoQRCode(c *ui.Context, log func(string)) { + display.QRCode(c, "https://mygo.egoist.dev/docs/ui", display.QRCodeOptions{}) +} + +func demoSignpost(c *ui.Context, log func(string)) { + boards := []string{"Town", "Dungeon", "Shop"} + if r := display.Signpost(c, boards, display.SignpostOptions{}); r.Clicked >= 0 { + log("to the " + boards[r.Clicked]) + } +} diff --git a/gallery/demos_layout.go b/gallery/demos_layout.go index 1ce6199..1e0edc9 100644 --- a/gallery/demos_layout.go +++ b/gallery/demos_layout.go @@ -5,6 +5,7 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" @@ -159,7 +160,7 @@ func demoGrid(c *ui.Context, log func(string)) { ui.Row(c).Gap(k.D(12)).AlignItems(ui.Start).Children(func() { for _, lines := range []layout.GridLines{layout.SolidLines, layout.DottedLines} { layout.Grid(c, len(items), layout.GridOptions{Columns: 3, Lines: lines}, func(i int) { - pixel.Icon(c, items[i], pixel.IconOptions{Scale: 2}) + display.Icon(c, items[i], display.IconOptions{Scale: 2}) }) } }) @@ -171,7 +172,7 @@ func demoTiles(c *ui.Context, log func(string)) { layout.Tiles(c, len(items), layout.TilesOptions{W: 28}, func(i int) { ui.Box(c).Fill().Center().Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Frame(0, 0, 28, 28, pixel.Raised, pixel.Face) - }).Children(func() { pixel.Icon(c, items[i], pixel.IconOptions{}) }) + }).Children(func() { display.Icon(c, items[i], display.IconOptions{}) }) }).Width(k.D(160)) } @@ -211,7 +212,7 @@ func demoLayers(c *ui.Context, log func(string)) { g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 6) }) }, func() { - ui.Box(c).Fill().Center().Children(func() { pixel.Icon(c, icon.Chest, pixel.IconOptions{Scale: 3}) }) + ui.Box(c).Fill().Center().Children(func() { display.Icon(c, icon.Chest, display.IconOptions{Scale: 3}) }) }, func() { ui.Box(c).Padding(k.D(3)).Children(func() { label(c, "LAYER 3", pixel.Ink) }) }).Size(k.D(120), k.D(64)) diff --git a/gallery/pages.go b/gallery/pages.go index 1c390ad..ce28265 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -6,6 +6,7 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -205,7 +206,7 @@ func GameOver(c *ui.Context, why string) bool { shadowText(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3) }) pixel.Text(c, why, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(why), 280))) - pixel.Icon(c, icon.Skull, pixel.IconOptions{Scale: 3}) + display.Icon(c, icon.Skull, display.IconOptions{Scale: 3}) cont = button(c, k.Msg("continue")) }) return cont diff --git a/gallery/registry.go b/gallery/registry.go index bad68f5..087ddde 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries) func Entries() []Entry { return entries } diff --git a/go.mod b/go.mod index 30e4550..a48ec9a 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI go 1.27.1 -require github.com/egoist/mygo v0.2.10 +require ( + github.com/egoist/mygo v0.2.10 + rsc.io/qr v0.2.0 +) require ( github.com/ebitengine/purego v0.11.1 // indirect diff --git a/go.sum b/go.sum index 3b02edf..ce94117 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2 github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/icon/icon_test.go b/icon/icon_test.go index 0a14f90..fec688f 100644 --- a/icon/icon_test.go +++ b/icon/icon_test.go @@ -75,7 +75,8 @@ func build(t *testing.T, ds map[string]decl, name string) *pixel.Sprite { func render(sp *pixel.Sprite, ink pixel.Slot) []byte { return ui.Render(func(c *ui.Context) { pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 1}) - pixel.Icon(c, sp, pixel.IconOptions{Slot: ink}) + k := pixel.K(c) + ui.Box(c).Size(12, 12).Draw(func(p *ui.Painter, r ui.Rect) { k.Canvas(p, r).Sprite(0, 0, sp, 0, ink, 1) }) }, 12, 12, 1).Pix } diff --git a/pixel/dither.go b/pixel/dither.go index b622ca6..b547a5f 100644 --- a/pixel/dither.go +++ b/pixel/dither.go @@ -4,6 +4,9 @@ import "fmt" var bayer = [4][4]int{{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}} +// Bayer is the 4×4 ordered threshold, 0–15, at x, y. +func Bayer(x, y int) int { return bayer[y&3][x&3] } + // Dither fills b below the level threshold, a elsewhere. func (g Canvas) Dither(x, y, w, h int, a, b Slot, level int) { checkLevel(level) diff --git a/pixel/element.go b/pixel/element.go index 7407775..6302f76 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -77,27 +77,6 @@ func WrapWidth(c *ui.Context, e *ui.Element) (int, func(r ui.Rect)) { } } -type IconOptions struct { - Slot Slot - Scale int - // Label names the icon; empty hides it from assistive technology. - Label string -} - -func Icon(c *ui.Context, sp *Sprite, o IconOptions) *ui.Element { - k := K(c) - scale := scaleOr1(o.Scale) - e := ui.Box(c).Size(k.D(sp.W*scale), k.D(sp.H*scale)) - if o.Label != "" { - e.Label(o.Label).Role(ui.RoleImage) - } else { - e.Role(ui.RoleNone) - } - return e.Draw(func(p *ui.Painter, r ui.Rect) { - k.Canvas(p, r).Sprite(0, 0, sp, 0, o.Slot, scale) - }) -} - func scaleOr1(s int) int { if s == 0 { return 1 diff --git a/pixel/element_test.go b/pixel/element_test.go index 973bd2b..771e664 100644 --- a/pixel/element_test.go +++ b/pixel/element_test.go @@ -150,22 +150,6 @@ func TestTextElement(t *testing.T) { } } -func TestIconElement(t *testing.T) { - sp := pixel.MustSprite("aa\naa\naa") - tt := ui.NewTester(func(c *ui.Context) { - pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Dot: 2}) - pixel.Icon(c, sp, pixel.IconOptions{Scale: 3, Label: "block"}) - pixel.Icon(c, sp, pixel.IconOptions{}) - }, 100, 100) - r, ok := tt.Find("block") - if !ok || r.W != 2*3*2 || r.H != 3*3*2 { - t.Fatalf("icon box %v found %v, want 12×18", r, ok) - } - if len(tt.Texts()) != 1 { - t.Fatalf("unlabeled icon is visible to assistive technology: %q", tt.Texts()) - } -} - func shellView(disabled *bool, clicks *int, st *pixel.State) func(c *ui.Context) { at := time.Unix(1_700_000_001, 250_000_000) return func(c *ui.Context) { diff --git a/pixel/messages.go b/pixel/messages.go index f1c06cd..d989103 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -9,6 +9,9 @@ var messages = map[Locale]map[string]string{ "search": "SEARCH", "empty": "NO ITEMS", "minimize": "MINIMIZE", "resize": "RESIZE", "scroll_up": "SCROLL UP", "scroll_down": "SCROLL DOWN", "up": "UP", "down": "DOWN", "left": "LEFT", "right": "RIGHT", "disabled": "DISABLED", + "remove": "REMOVE", "online": "ONLINE", "away": "AWAY", "busy": "BUSY", "offline": "OFFLINE", + "gold": "GOLD", "silver": "SILVER", "bronze": "BRONZE", + "common": "COMMON", "uncommon": "UNCOMMON", "rare": "RARE", "epic": "EPIC", "legendary": "LEGENDARY", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -16,6 +19,9 @@ var messages = map[Locale]map[string]string{ "search": "搜索", "empty": "空空如也", "minimize": "最小化", "resize": "调整大小", "scroll_up": "向上滚动", "scroll_down": "向下滚动", "up": "上", "down": "下", "left": "左", "right": "右", "disabled": "已禁用", + "remove": "移除", "online": "在线", "away": "离开", "busy": "忙碌", "offline": "离线", + "gold": "金", "silver": "银", "bronze": "铜", + "common": "普通", "uncommon": "优秀", "rare": "稀有", "epic": "史诗", "legendary": "传说", }, } diff --git a/pixel/micro.go b/pixel/micro.go index 74dab3e..de25bc8 100644 --- a/pixel/micro.go +++ b/pixel/micro.go @@ -90,6 +90,16 @@ func microGlyph(r rune) [MicroHeight]uint8 { return g } +// MicroOK reports whether the micro font has all of s. +func MicroOK(s string) bool { + for _, r := range s { + if _, ok := micro[unicode.ToUpper(r)]; !ok { + return false + } + } + return true +} + // MicroWidth returns the width of s in dots. func MicroWidth(s string) int { n := 0 diff --git a/pixel/palette.go b/pixel/palette.go index 33a6390..6866f57 100644 --- a/pixel/palette.go +++ b/pixel/palette.go @@ -31,6 +31,11 @@ const ( const Slots = 16 +var slotNames = [Slots]string{"Ink", "Back", "Face", "Lit", "Shade", "Edge", "Faint", "Well", + "Accent", "AccentLit", "AccentInk", "Good", "Warn", "Bad", "Info", "Magic"} + +func (s Slot) String() string { return slotNames[s] } + type Season uint8 const (