Add display components 041-065; move Icon into display; QR via rsc.io/qr

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 18:37:54 -04:00
1 parent 59fde4096d
commit 0247a130f5
41 files changed
+2152 -44

No files matched your search

+1
View File
@@ -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` |
+149
View File
@@ -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)
}
})
}
+39
View File
@@ -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)
})
}
+73
View File
@@ -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)
}
+54
View File
@@ -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) }
+330
View File
@@ -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)
}()
}
}
+80
View File
@@ -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})
}
+21
View File
@@ -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)})
})
}
+35
View File
@@ -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)
}
})
}
+74
View File
@@ -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)
}
})
}
+20
View File
@@ -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) })
}
+90
View File
@@ -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()
}
+24
View File
@@ -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)
})
}
+61
View File
@@ -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)
}
+113
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
+31
View File
@@ -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) })
})
})
}
+75
View File
@@ -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
}
+64
View File
@@ -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)
}
}
+48
View File
@@ -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
}
+93
View File
@@ -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()
}
+32
View File
@@ -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)
})
}
+49
View File
@@ -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
}
+46
View File
@@ -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})
})
}
})
}
+48
View File
@@ -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)
}
})
}
+28
View File
@@ -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)
})
}
})
}
+76
View File
@@ -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()
}
+51
View File
@@ -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)
}
})
}
+272
View File
@@ -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])
}
}
+4 -3
View File
@@ -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))
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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 }
+4 -1
View File
@@ -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
+2
View File
@@ -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=
+2 -1
View File
@@ -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
}
+3
View File
@@ -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)
-21
View File
@@ -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
-16
View File
@@ -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) {
+6
View File
@@ -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": "传说",
},
}
+10
View File
@@ -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
+5
View File
@@ -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 (