Add canvas, dither, sprites, clock and pixel elements

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 16:02:31 -04:00
1 parent 538031af31
commit d33bf0cbbb
11 files changed
+1274 -11

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
package pixel
import (
"fmt"
"math"
"slices"
"time"
"github.com/egoist/mygo/ui"
)
// Canvas draws dots for one Draw call; coordinates are dots.
type Canvas struct {
p painter
k Kit
gx, gy int
W, H int
}
// painter is what Canvas needs of *ui.Painter.
type painter interface {
Fill(r ui.Rect, c ui.Color, radius float32)
After(d time.Duration)
Now() time.Time
}
func (k Kit) Canvas(p *ui.Painter, r ui.Rect) Canvas {
gx := int(math.Round(float64(r.X / k.dot)))
gy := int(math.Round(float64(r.Y / k.dot)))
return Canvas{p: p, k: k, gx: gx, gy: gy, W: int(r.W / k.dot), H: int(r.H / k.dot)}
}
func (g Canvas) now() time.Time {
if g.k.clock != nil {
return g.k.clock()
}
return g.p.Now()
}
func (g Canvas) Rect(x, y, w, h int, s Slot) {
if w <= 0 || h <= 0 {
return
}
d := g.k.dot
g.p.Fill(ui.Rect{X: float32(g.gx+x) * d, Y: float32(g.gy+y) * d, W: float32(w) * d, H: float32(h) * d}, g.k.C(s), 0)
}
func (g Canvas) Dot(x, y int, s Slot) { g.Rect(x, y, 1, 1, s) }
func (g Canvas) HLine(x, y, w int, s Slot) { g.Rect(x, y, w, 1, s) }
func (g Canvas) VLine(x, y, h int, s Slot) { g.Rect(x, y, 1, h, s) }
// Line draws a Bresenham line, both ends included.
func (g Canvas) Line(x0, y0, x1, y1 int, s Slot) {
dx, dy := abs(x1-x0), -abs(y1-y0)
sx, sy := sign(x1-x0), sign(y1-y0)
e := dx + dy
var pts [][2]int
for {
pts = append(pts, [2]int{x0, y0})
if x0 == x1 && y0 == y1 {
break
}
e2 := 2 * e
if e2 >= dy {
e += dy
x0 += sx
}
if e2 <= dx {
e += dx
y0 += sy
}
}
g.points(pts, s)
}
// points merges dots in a row or column into rects.
func (g Canvas) points(pts [][2]int, s Slot) {
if len(pts) == 0 {
return
}
x0, y0, x1, y1 := pts[0][0], pts[0][1], pts[0][0], pts[0][1]
for _, p := range pts[1:] {
row := y0 == y1 && p[1] == y0 && (p[0] == x1+1 || p[0] == x0-1)
col := x0 == x1 && p[0] == x0 && (p[1] == y1+1 || p[1] == y0-1)
if row || col {
x0, y0, x1, y1 = min(x0, p[0]), min(y0, p[1]), max(x1, p[0]), max(y1, p[1])
continue
}
g.Rect(x0, y0, x1-x0+1, y1-y0+1, s)
x0, y0, x1, y1 = p[0], p[1], p[0], p[1]
}
g.Rect(x0, y0, x1-x0+1, y1-y0+1, s)
}
// Circle draws a midpoint circle outline of radius r.
func (g Canvas) Circle(cx, cy, r int, s Slot) {
rows := map[int][]int{}
octants(r, func(x, y int) {
for _, p := range [][2]int{{x, y}, {y, x}, {-x, y}, {-y, x}, {x, -y}, {y, -x}, {-x, -y}, {-y, -x}} {
rows[cy+p[1]] = append(rows[cy+p[1]], cx+p[0])
}
})
for y, xs := range rows {
slices.Sort(xs)
xs = slices.Compact(xs)
start := 0
for i := 1; i <= len(xs); i++ {
if i == len(xs) || xs[i] != xs[i-1]+1 {
g.HLine(xs[start], y, xs[i-1]-xs[start]+1, s)
start = i
}
}
}
}
// Disc fills a midpoint circle of radius r.
func (g Canvas) Disc(cx, cy, r int, s Slot) {
half := make([]int, r+1)
octants(r, func(x, y int) {
half[y] = max(half[y], x)
half[x] = max(half[x], y)
})
for dy := -r; dy <= r; dy++ {
h := half[abs(dy)]
g.HLine(cx-h, cy+dy, 2*h+1, s)
}
}
func octants(r int, plot func(x, y int)) {
if r < 0 {
panic(fmt.Sprintf("pixel: negative radius %d", r))
}
x, y, e := r, 0, 1-r
for x >= y {
plot(x, y)
y++
if e < 0 {
e += 2*y + 1
} else {
x--
e += 2*(y-x) + 1
}
}
}
// Bevel lights the top-left edge and darkens the bottom-right.
func (g Canvas) Bevel(x, y, w, h int, light, dark Slot) {
g.HLine(x, y, w-1, light)
g.VLine(x, y+1, h-2, light)
g.HLine(x+1, y+h-1, w-1, dark)
g.VLine(x+w-1, y, h-1, dark)
}
type FrameStyle uint8
const (
Raised FrameStyle = iota
Sunken
Flat
Double
Window
Notched
)
// Frame draws an outlined, filled box in a style.
func (g Canvas) Frame(x, y, w, h int, style FrameStyle, fill Slot) {
if w < 3 || h < 3 {
panic(fmt.Sprintf("pixel: frame %d×%d smaller than 3×3", w, h))
}
notch := style == Window || style == Notched
g.outline(x, y, w, h, Edge, notch)
g.Rect(x+1, y+1, w-2, h-2, fill)
switch style {
case Raised:
g.Bevel(x+1, y+1, w-2, h-2, Lit, Shade)
case Sunken:
g.Bevel(x+1, y+1, w-2, h-2, Shade, Lit)
case Double:
if w >= 5 && h >= 5 {
g.outline(x+2, y+2, w-4, h-4, Edge, false)
}
case Window:
g.outline(x+1, y+1, w-2, h-2, Lit, false)
if w >= 5 && h >= 5 {
g.outline(x+2, y+2, w-4, h-4, Shade, false)
}
case Flat, Notched:
default:
panic(fmt.Sprintf("pixel: unknown frame style %d", style))
}
}
func (g Canvas) outline(x, y, w, h int, s Slot, notch bool) {
n := 0
if notch {
n = 1
}
g.HLine(x+n, y, w-2*n, s)
g.HLine(x+n, y+h-1, w-2*n, s)
g.VLine(x, y+1, h-2, s)
g.VLine(x+w-1, y+1, h-2, s)
}
// Text draws one line with its top-left at x, y.
func (g Canvas) Text(x, y int, s string, ink Slot, scale int) {
checkScale(scale)
for _, r := range s {
gl := glyphOf(r)
top := y + gl.top()*scale
for row := range gl.h {
g.bits(x+gl.x*scale, top+row*scale, gl.w, scale, ink, func(col int) bool { return gl.on(col, row) })
}
x += gl.adv * scale
}
}
// Micro draws s in the 3×5 micro font.
func (g Canvas) Micro(x, y int, s string, ink Slot, scale int) {
checkScale(scale)
for _, r := range s {
gl := microGlyph(r)
for row, bits := range gl {
g.bits(x, y+row*scale, 3, scale, ink, func(col int) bool { return bits&(4>>col) != 0 })
}
x += MicroAdvance * scale
}
}
// bits draws one row's set dots as merged runs.
func (g Canvas) bits(x, y, w, scale int, s Slot, on func(int) bool) {
runs(w, on, func(i, j int) { g.Rect(x+i*scale, y, (j-i)*scale, scale, s) })
}
// runs emits each run [i, j) of set dots.
func runs(w int, on func(int) bool, emit func(i, j int)) {
for i := 0; i < w; {
if !on(i) {
i++
continue
}
j := i
for j < w && on(j) {
j++
}
emit(i, j)
i = j
}
}
func checkScale(scale int) {
if scale < 1 || scale > 4 {
panic(fmt.Sprintf("pixel: scale %d outside 1–4", scale))
}
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
func sign(v int) int {
switch {
case v > 0:
return 1
case v < 0:
return -1
}
return 0
}
+241
View File
@@ -0,0 +1,241 @@
package pixel_test
import (
"testing"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestRectAndLines(t *testing.T) {
d := canvas(t, 8, 6, func(g pixel.Canvas) {
g.Rect(1, 1, 3, 2, pixel.Accent)
g.HLine(0, 4, 5, pixel.Good)
g.VLine(7, 0, 6, pixel.Bad)
g.Dot(6, 5, pixel.Magic)
g.Rect(5, 1, 0, 3, pixel.Ink)
})
if d.count(1, 1, 3, 2, pixel.Accent) != 6 || d.at(0, 1) != pixel.Back || d.at(4, 2) != pixel.Back {
t.Fatal("Rect covers the wrong dots")
}
if d.count(0, 4, 5, 1, pixel.Good) != 5 || d.at(5, 4) != pixel.Back {
t.Fatal("HLine covers the wrong dots")
}
if d.count(7, 0, 1, 6, pixel.Bad) != 6 || d.at(6, 5) != pixel.Magic {
t.Fatal("VLine or Dot wrong")
}
if d.count(5, 1, 1, 3, pixel.Ink) != 0 {
t.Fatal("zero-width Rect drew")
}
}
func TestLineBresenham(t *testing.T) {
cases := [][4]int{{0, 0, 9, 3}, {9, 0, 0, 3}, {2, 0, 4, 9}, {0, 9, 6, 0}, {3, 3, 3, 3}, {0, 5, 9, 5}}
for _, c := range cases {
d := canvas(t, 10, 10, func(g pixel.Canvas) { g.Line(c[0], c[1], c[2], c[3], pixel.Ink) })
if d.at(c[0], c[1]) != pixel.Ink || d.at(c[2], c[3]) != pixel.Ink {
t.Fatalf("line %v misses an end", c)
}
dx, dy := c[2]-c[0], c[3]-c[1]
major := max(dx, -dx, dy, -dy)
if got := d.count(0, 0, 10, 10, pixel.Ink); got != major+1 {
t.Fatalf("line %v has %d dots, want %d", c, got, major+1)
}
xMajor := max(dx, -dx) >= max(dy, -dy)
for i := range 10 {
var n int
if xMajor {
n = d.count(i, 0, 1, 10, pixel.Ink)
} else {
n = d.count(0, i, 10, 1, pixel.Ink)
}
inside := i >= min(c[0], c[2]) && i <= max(c[0], c[2])
if !xMajor {
inside = i >= min(c[1], c[3]) && i <= max(c[1], c[3])
}
if inside != (n == 1) || n > 1 {
t.Fatalf("line %v: %d dots across major step %d", c, n, i)
}
}
}
}
func TestLineTieBreak(t *testing.T) {
d := canvas(t, 3, 2, func(g pixel.Canvas) { g.Line(0, 0, 2, 1, pixel.Ink) })
for _, p := range [][2]int{{0, 0}, {1, 1}, {2, 1}} {
if d.at(p[0], p[1]) != pixel.Ink {
t.Fatalf("Line(0,0,2,1) misses %v", p)
}
}
if d.at(1, 0) != pixel.Back {
t.Fatal("Line(0,0,2,1) took the wrong tie")
}
}
func TestCircleAndDisc(t *testing.T) {
const cx, cy, r = 7, 7, 5
disc := canvas(t, 15, 15, func(g pixel.Canvas) { g.Disc(cx, cy, r, pixel.Info) })
ring := canvas(t, 15, 15, func(g pixel.Canvas) { g.Circle(cx, cy, r, pixel.Ink) })
for _, p := range [][2]int{{cx + r, cy}, {cx - r, cy}, {cx, cy + r}, {cx, cy - r}} {
if disc.at(p[0], p[1]) != pixel.Info || ring.at(p[0], p[1]) != pixel.Ink {
t.Fatalf("radius point %v missing", p)
}
}
if disc.at(cx, cy) != pixel.Info || ring.at(cx, cy) != pixel.Back || disc.at(cx+r, cy+r) != pixel.Back {
t.Fatal("disc must be solid, circle hollow, corners empty")
}
for y := range 15 {
for x := range 15 {
if disc.at(x, y) != disc.at(2*cx-x, y) || disc.at(x, y) != disc.at(x, 2*cy-y) || disc.at(x, y) != disc.at(y, x) {
t.Fatalf("disc not symmetric at %d,%d", x, y)
}
if ring.at(x, y) == pixel.Ink && disc.at(x, y) != pixel.Info {
t.Fatalf("circle dot %d,%d outside the disc", x, y)
}
}
}
}
func TestCircleExact(t *testing.T) {
ring := canvas(t, 7, 7, func(g pixel.Canvas) { g.Circle(3, 3, 3, pixel.Ink) })
disc := canvas(t, 7, 7, func(g pixel.Canvas) { g.Disc(3, 3, 3, pixel.Ink) })
wantRing := []string{"..###..", ".#...#.", "#.....#", "#.....#", "#.....#", ".#...#.", "..###.."}
wantDisc := []string{"..###..", ".#####.", "#######", "#######", "#######", ".#####.", "..###.."}
for y := range 7 {
for x := range 7 {
if (ring.at(x, y) == pixel.Ink) != (wantRing[y][x] == '#') || (disc.at(x, y) == pixel.Ink) != (wantDisc[y][x] == '#') {
t.Fatalf("radius 3 dot %d,%d differs from the midpoint shape", x, y)
}
}
}
}
func TestCircleRadiusSix(t *testing.T) {
octant := [][2]int{{6, 0}, {6, 1}, {6, 2}, {5, 3}, {4, 4}}
want := map[[2]int]bool{}
for _, p := range octant {
for _, q := range [][2]int{{p[0], p[1]}, {p[1], p[0]}} {
for _, s := range [][2]int{{1, 1}, {-1, 1}, {1, -1}, {-1, -1}} {
want[[2]int{6 + s[0]*q[0], 6 + s[1]*q[1]}] = true
}
}
}
d := canvas(t, 13, 13, func(g pixel.Canvas) { g.Circle(6, 6, 6, pixel.Ink) })
for y := range 13 {
for x := range 13 {
if (d.at(x, y) == pixel.Ink) != want[[2]int{x, y}] {
t.Fatalf("radius 6 dot %d,%d differs from the midpoint shape", x, y)
}
}
}
}
func TestBayerMatrix(t *testing.T) {
want := [4][4]int{{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}
first := [4][4]int{}
for level := 16; level >= 1; level-- {
d := canvas(t, 4, 4, func(g pixel.Canvas) { g.Veil(0, 0, 4, 4, pixel.Ink, level) })
for y := range 4 {
for x := range 4 {
if d.at(x, y) == pixel.Ink {
first[y][x] = level - 1
}
}
}
}
if first != want {
t.Fatalf("Bayer thresholds %v, want %v", first, want)
}
}
func TestFrameStyles(t *testing.T) {
const w, h = 8, 7
want := map[pixel.FrameStyle][][3]int{
pixel.Flat: {{0, 0, int(pixel.Edge)}, {1, 1, int(pixel.Face)}, {w - 1, h - 1, int(pixel.Edge)}},
pixel.Raised: {{0, 0, int(pixel.Edge)}, {1, 1, int(pixel.Lit)}, {w - 2, h - 2, int(pixel.Shade)}, {2, 2, int(pixel.Face)}},
pixel.Sunken: {{1, 1, int(pixel.Shade)}, {w - 2, h - 2, int(pixel.Lit)}, {2, 2, int(pixel.Face)}},
pixel.Double: {{0, 0, int(pixel.Edge)}, {1, 1, int(pixel.Face)}, {2, 2, int(pixel.Edge)}, {3, 3, int(pixel.Face)}},
pixel.Window: {{0, 0, int(pixel.Back)}, {1, 0, int(pixel.Edge)}, {1, 1, int(pixel.Lit)}, {2, 2, int(pixel.Shade)}, {3, 3, int(pixel.Face)}},
pixel.Notched: {{0, 0, int(pixel.Back)}, {1, 0, int(pixel.Edge)}, {0, 1, int(pixel.Edge)}, {1, 1, int(pixel.Face)}},
}
for style, checks := range want {
d := canvas(t, w, h, func(g pixel.Canvas) { g.Frame(0, 0, w, h, style, pixel.Face) })
for _, ck := range checks {
if got := d.at(ck[0], ck[1]); got != pixel.Slot(ck[2]) {
t.Errorf("style %d dot %d,%d = slot %d, want %d", style, ck[0], ck[1], got, ck[2])
}
}
}
mustPanic(t, "2×2 frame", func() {
canvas(t, 4, 4, func(g pixel.Canvas) { g.Frame(0, 0, 2, 2, pixel.Flat, pixel.Face) })
})
}
func TestDitherLevels(t *testing.T) {
for level := 0; level <= 16; level++ {
d := canvas(t, 8, 8, func(g pixel.Canvas) { g.Dither(0, 0, 8, 8, pixel.Face, pixel.Ink, level) })
for _, b := range [][2]int{{0, 0}, {4, 0}, {0, 4}, {4, 4}} {
if n := d.count(b[0], b[1], 4, 4, pixel.Ink); n != level {
t.Fatalf("level %d: 4×4 block at %v has %d ink dots", level, b, n)
}
}
}
d := canvas(t, 6, 6, func(g pixel.Canvas) { g.Checker(0, 0, 6, 6, pixel.Ink) })
for y := range 6 {
for x := range 6 {
if want := (x+y)%2 == 0; (d.at(x, y) == pixel.Ink) != want {
t.Fatalf("checker dot %d,%d", x, y)
}
}
}
}
func TestDitherAnchorsToWindow(t *testing.T) {
d := canvas(t, 8, 4, func(g pixel.Canvas) {
g.Rect(0, 0, 8, 4, pixel.Accent)
g.Veil(1, 0, 3, 4, pixel.Ink, 8)
g.Veil(4, 1, 4, 3, pixel.Ink, 8)
})
for y := range 4 {
for x := 1; x < 8; x++ {
if x >= 4 && y == 0 {
continue
}
want := pixel.Accent
if (x+y)%2 == 0 {
want = pixel.Ink
}
if got := d.at(x, y); got != want {
t.Fatalf("veil dot %d,%d = %d, want %d", x, y, got, want)
}
}
}
mustPanic(t, "level 17", func() {
canvas(t, 2, 2, func(g pixel.Canvas) { g.Veil(0, 0, 1, 1, pixel.Ink, 17) })
})
}
func TestTextAndMicroDraw(t *testing.T) {
a := []string{"...#...", "...#...", "..#.#..", "..#.#..", ".#...#.", ".#####.", ".#...#.", "#.....#", "#.....#"}
for _, scale := range []int{1, 2} {
d := canvas(t, 8*scale, 16*scale, func(g pixel.Canvas) { g.Text(0, 0, "A", pixel.Ink, scale) })
for row, line := range a {
for col, ch := range line {
if got := d.at(col*scale, (4+row)*scale) == pixel.Ink; got != (ch == '#') {
t.Fatalf("×%d 'A' dot %d,%d", scale, col, row)
}
}
}
if d.count(0, 0, 8*scale, 4*scale, pixel.Ink) != 0 {
t.Fatalf("×%d 'A' drew above its top", scale)
}
}
one := []string{".#.", "##.", ".#.", ".#.", "###"}
d := canvas(t, 8, 5, func(g pixel.Canvas) { g.Micro(0, 0, "11", pixel.Good, 1) })
for row, line := range one {
for col, ch := range line {
if (d.at(col, row) == pixel.Good) != (ch == '#') || (d.at(col+4, row) == pixel.Good) != (ch == '#') {
t.Fatalf("micro '1' dot %d,%d", col, row)
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package pixel
import (
"fmt"
"sync/atomic"
"time"
"github.com/egoist/mygo/ui"
)
var due atomic.Int64
// Due is the earliest repaint asked for since ResetDue.
func Due() time.Time {
n := due.Load()
if n == 0 {
return time.Time{}
}
return time.Unix(0, n)
}
func ResetDue() { due.Store(0) }
func ask(at int64) {
for {
cur := due.Load()
if cur != 0 && cur <= at || due.CompareAndSwap(cur, at) {
return
}
}
}
// Step is the frame at fps, frozen under Reduce Motion.
func (g Canvas) Step(fps int) int {
checkFPS(fps)
if g.k.reduce {
return 0
}
return g.Busy(fps)
}
// Busy is the frame at fps, ignoring Reduce Motion.
func (g Canvas) Busy(fps int) int {
n, d := frameAt(g.now(), fps)
g.p.After(d)
ask(g.p.Now().Add(d).UnixNano())
return n
}
// Tick is the frame at fps, rebuilding the view.
func (k Kit) Tick(c *ui.Context, fps int) int {
n, d := frameAt(k.now, fps)
c.After(d)
ask(c.Now().Add(d).UnixNano())
return n
}
// frameAt is the frame at t and the next wait.
func frameAt(t time.Time, fps int) (int, time.Duration) {
checkFPS(fps)
period := int64(time.Second) / int64(fps)
ns := t.UnixNano()
n := ns / period
return int(n), time.Duration((n+1)*period - ns)
}
func checkFPS(fps int) {
if fps < 1 || fps > 60 {
panic(fmt.Sprintf("pixel: fps %d outside 1–60", fps))
}
}
+39
View File
@@ -0,0 +1,39 @@
package pixel
import "fmt"
var bayer = [4][4]int{{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}
// 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)
if level <= 8 {
g.Rect(x, y, w, h, a)
g.cells(x, y, w, h, b, func(t int) bool { return t < level })
return
}
g.Rect(x, y, w, h, b)
g.cells(x, y, w, h, a, func(t int) bool { return t >= level })
}
// Veil dithers s over what is already there.
func (g Canvas) Veil(x, y, w, h int, s Slot, level int) {
checkLevel(level)
g.cells(x, y, w, h, s, func(t int) bool { return t < level })
}
// Checker is a half Veil: every other dot.
func (g Canvas) Checker(x, y, w, h int, s Slot) { g.Veil(x, y, w, h, s, 8) }
func (g Canvas) cells(x, y, w, h int, s Slot, on func(t int) bool) {
for j := range h {
row := bayer[(g.gy+y+j)&3]
g.bits(x, y+j, w, 1, s, func(i int) bool { return on(row[(g.gx+x+i)&3]) })
}
}
func checkLevel(level int) {
if level < 0 || level > 16 {
panic(fmt.Sprintf("pixel: dither level %d outside 0–16", level))
}
}
+147
View File
@@ -0,0 +1,147 @@
package pixel
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
)
type TextOptions struct {
Slot Slot
// Scale is 1–4; zero means 1.
Scale int
// Wrap breaks lines at the width the layout gives.
Wrap bool
MaxLines int
}
// Text is a pixel-font label that screen readers read.
func Text(c *ui.Context, s string, o TextOptions) *ui.Element {
k := K(c)
scale := scaleOr1(o.Scale)
e := ui.Box(c).Label(s).Role(ui.RoleText)
width, check := 0, func(ui.Rect) {}
if o.Wrap {
width, check = WrapWidth(c, e)
}
lines := textLines(s, width/scale, o.MaxLines)
if o.Wrap {
e.Height(k.D(len(lines) * LineHeight * scale))
} else {
w := 0
for _, l := range lines {
w = max(w, Measure(l))
}
e.Size(k.D(w*scale), k.D(len(lines)*LineHeight*scale))
}
return e.Draw(func(p *ui.Painter, r ui.Rect) {
check(r)
g := k.Canvas(p, r)
for i, l := range lines {
g.Text(0, i*LineHeight*scale, l, o.Slot, scale)
}
})
}
// textLines wraps s, cutting to maxLines with an ellipsis.
func textLines(s string, width, maxLines int) []string {
if maxLines < 0 {
panic(fmt.Sprintf("pixel: MaxLines %d", maxLines))
}
lines := Lines(s, width)
if maxLines == 0 || len(lines) <= maxLines {
return lines
}
lines = lines[:maxLines]
last := []rune(strings.TrimRight(lines[maxLines-1], " "))
for width > 0 && len(last) > 0 && Measure(string(last)+"…") > width {
last = last[:len(last)-1]
}
lines[maxLines-1] = string(last) + "…"
return lines
}
type wrapKey struct{}
// WrapWidth is e's last painted width; Draw must call check.
func WrapWidth(c *ui.Context, e *ui.Element) (int, func(r ui.Rect)) {
dot := K(c).dot
w := ui.Local(e, wrapKey{}, func() int { return 0 })
used := *w
return used, func(r ui.Rect) {
if now := int(r.W / dot); now != *w {
*w = now
c.Invalidate()
}
}
}
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
}
checkScale(s)
return s
}
// State is how a control looks this frame.
type State struct{ Hover, Press, Focus, Disabled bool }
// Shell wraps base in an enabled box that paints.
func Shell(c *ui.Context, base func() *ui.Element) (shell, b *ui.Element, st State) {
shell = ui.Box(c)
shell.Children(func() { b = base().Fill().FocusRing(false) })
st = State{Hover: b.Hovered(), Press: b.Pressed(), Focus: b.FocusVisible(), Disabled: b.IsDisabled()}
return shell, b, st
}
var hand = MustSprite(`
.eee......
elllleeee.
elllllllle
elllleeee.
elllle....
.eeee.....
`)
// FocusMark rings a box with dots and points a hand.
func (g Canvas) FocusMark(x, y, w, h int) {
rx, ry, rw, rh := x-2, y-2, w+4, h+4
for i := range rw {
g.ant(rx+i, ry)
g.ant(rx+i, ry+rh-1)
}
for j := 1; j < rh-1; j++ {
g.ant(rx, ry+j)
g.ant(rx+rw-1, ry+j)
}
g.Sprite(rx-hand.W-1+g.Step(4)%2, y+(h-hand.H)/2, hand, 0, Edge, 1)
}
func (g Canvas) ant(x, y int) {
if (g.gx+x+g.gy+y)&1 == 0 {
g.Dot(x, y, Accent)
}
}
+242
View File
@@ -0,0 +1,242 @@
package pixel_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestSpriteLettersMatchSlots(t *testing.T) {
sp := pixel.MustSprite("kbflsenwaAigyrcm")
d := canvas(t, 16, 1, func(g pixel.Canvas) { g.Sprite(0, 0, sp, 0, pixel.Ink, 1) })
for i := range 16 {
if got := d.at(i, 0); got != pixel.Slot(i) {
t.Fatalf("letter %d drew slot %d", i, got)
}
}
}
func TestSpriteFramesMirrorScale(t *testing.T) {
sp := pixel.MustSprite(`
#a.
..g
.r.
.r.
`)
if sp.W != 3 || sp.H != 2 || sp.Frames() != 2 {
t.Fatalf("sprite %d×%d with %d frames", sp.W, sp.H, sp.Frames())
}
d := canvas(t, 6, 4, func(g pixel.Canvas) {
g.Sprite(0, 0, sp, 0, pixel.Magic, 1)
g.Sprite(3, 0, sp, 3, pixel.Ink, 1)
g.Sprite(0, 2, sp.Mirror(), 0, pixel.Magic, 1)
})
checks := map[[2]int]pixel.Slot{
{0, 0}: pixel.Magic, {1, 0}: pixel.Accent, {2, 0}: pixel.Back, {2, 1}: pixel.Good,
{4, 0}: pixel.Bad, {4, 1}: pixel.Bad, {3, 0}: pixel.Back,
{2, 2}: pixel.Magic, {1, 2}: pixel.Accent, {0, 3}: pixel.Good,
}
for p, want := range checks {
if got := d.at(p[0], p[1]); got != want {
t.Fatalf("dot %v = %d, want %d", p, got, want)
}
}
big := canvas(t, 6, 4, func(g pixel.Canvas) { g.Sprite(0, 0, sp, 0, pixel.Magic, 2) })
if big.count(0, 0, 2, 2, pixel.Magic) != 4 || big.count(2, 0, 2, 2, pixel.Accent) != 4 {
t.Fatal("scale 2 must fill 2×2 dots per art pixel")
}
for name, art := range map[string]string{"ragged": "ab\nabc", "letter": "az", "empty": " ", "frames": "aa\n\naa\naa"} {
mustPanic(t, name, func() { pixel.MustSprite(art) })
}
}
func TestClock(t *testing.T) {
at := time.Unix(1_700_000_001, 250_000_000)
var step, busy, tick int
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }})
k := pixel.K(c)
tick = k.Tick(c, 8)
ui.Box(c).Size(4, 4).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
step, busy = g.Step(4), g.Busy(4)
})
}, 10, 10)
if want := int(at.UnixNano() / int64(time.Second/4)); step != want || busy != want {
t.Fatalf("Step %d Busy %d, want %d", step, busy, want)
}
if want := int(at.UnixNano() / int64(time.Second/8)); tick != want {
t.Fatalf("Tick %d, want %d", tick, want)
}
pixel.ResetDue()
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if step != 0 || busy == 0 {
t.Fatalf("under Reduce Motion: Step %d (want 0), Busy %d", step, busy)
}
if due := time.Until(pixel.Due()); due <= 0 || due > 125*time.Millisecond {
t.Fatalf("Due in %v, want the 8 fps wait on the real clock", due)
}
pixel.ResetDue()
if !pixel.Due().IsZero() {
t.Fatal("ResetDue left a deadline")
}
mustPanic(t, "fps 0", func() {
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
pixel.K(c).Tick(c, 0)
}, 10, 10)
})
mustPanic(t, "Step 0 under Reduce Motion", func() {
on := false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
k := pixel.K(c)
ui.Box(c).Size(4, 4).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
k.Canvas(p, r).Step(0)
}
})
}, 10, 10)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
on = true
tt.Frame()
})
}
func TestWrapFollowsSameFrameResize(t *testing.T) {
const s = "像素风格组件库,每个点都经过认真摆放,在限制中创造丰富。"
width := float32(120)
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Dot: 2})
ui.Column(c).Width(width).Children(func() {
pixel.Text(c, s, pixel.TextOptions{Wrap: true})
})
}, 400, 800)
for _, w := range []float32{60, 200, 120} {
width = w
tt.Frame()
r, _ := tt.Find(s)
if want := float32(len(pixel.Lines(s, int(w/2))) * 16 * 2); r.H != want {
t.Fatalf("width %v: text height %v, want %v", w, r.H, want)
}
}
}
func TestTextElement(t *testing.T) {
var width float32
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Dot: 2})
pixel.Text(c, "HELLO", pixel.TextOptions{Scale: 2})
ui.Column(c).Width(120).Children(func() {
pixel.Text(c, "像素风格组件库,每个点都经过认真摆放。", pixel.TextOptions{Wrap: true})
})
pixel.Text(c, "one\ntwo\nthree", pixel.TextOptions{MaxLines: 2})
width = float32(pixel.Measure("HELLO") * 2 * 2)
}, 400, 400)
r, ok := tt.Find("HELLO")
if !ok || r.W != width || r.H != 2*16*2 {
t.Fatalf("HELLO box %v (found %v), want %v×64", r, ok, width)
}
r, _ = tt.Find("像素风格组件库,每个点都经过认真摆放。")
if lines := len(pixel.Lines("像素风格组件库,每个点都经过认真摆放。", 60)); r.H != float32(lines*16*2) || lines < 3 {
t.Fatalf("wrapped box %v, want %d lines", r, lines)
}
if r, _ = tt.Find("one\ntwo\nthree"); r.H != 2*16*2 {
t.Fatalf("MaxLines box height %v, want 64", r.H)
}
}
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) {
pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 2, Now: func() time.Time { return at }})
k := pixel.K(c)
ui.Box(c).Size(k.D(16), k.D(4))
ui.Row(c).Children(func() {
ui.Box(c).Size(k.D(16), k.D(8))
shell, base, s := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Label("go").Disabled(*disabled)
})
*st = s
if base.Clicked() {
*clicks++
}
shell.Size(k.D(12), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, 12, 8, pixel.Accent)
if s.Focus {
g.FocusMark(0, 0, 12, 8)
}
})
})
}
}
func TestShellKeepsLookAndBehavior(t *testing.T) {
disabled, clicks, st := false, 0, pixel.State{}
tt := ui.NewTester(shellView(&disabled, &clicks, &st), 100, 100)
tt.ClickAt(40, 16)
if clicks != 1 {
t.Fatalf("enabled shell: %d clicks", clicks)
}
tt.Key(0, ui.KeyTab)
tt.Frame()
if !st.Focus {
t.Fatal("Tab did not give the base visible focus")
}
d := dots{t: t, img: tt.Image(), pal: pixel.SummerDay}
if d.at(14, 2) != pixel.Accent || d.at(15, 2) != pixel.Back || d.at(29, 13) != pixel.Accent {
t.Fatal("focus ring dots missing around the shell")
}
if d.count(3, 5, 12, 6, pixel.Edge) == 0 || d.count(3, 5, 12, 6, pixel.Lit) == 0 {
t.Fatal("hand cursor missing left of the shell")
}
disabled = true
tt.Frame()
tt.ClickAt(40, 16)
if clicks != 1 || !st.Disabled || st.Focus {
t.Fatalf("disabled: clicks %d, state %+v", clicks, st)
}
d = dots{t: t, img: tt.Image(), pal: pixel.SummerDay}
if d.count(16, 4, 12, 8, pixel.Accent) != 96 {
t.Fatal("disabled base dimmed the shell's look")
}
}
func TestVeilAnchorsAcrossElements(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 2})
k := pixel.K(c)
ui.Box(c).Size(k.D(1), k.D(1))
ui.Row(c).Children(func() {
ui.Box(c).Size(k.D(3), k.D(2))
ui.Box(c).Size(k.D(4), k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Checker(0, 0, 4, 2, pixel.Ink)
})
})
}, 14, 6)
d := dots{t: t, img: tt.Image(), pal: pixel.SummerDay}
if d.at(3, 1) != pixel.Ink || d.at(4, 1) != pixel.Back || d.at(3, 2) != pixel.Back || d.at(4, 2) != pixel.Ink {
t.Fatal("checker phase follows the element, not the window")
}
}
+79
View File
@@ -0,0 +1,79 @@
package pixel
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestRuns(t *testing.T) {
cases := map[string][][2]int{
"##..###.#": {{0, 2}, {4, 7}, {8, 9}},
"....": nil,
"####": {{0, 4}},
".#.#": {{1, 2}, {3, 4}},
}
for pat, want := range cases {
var got [][2]int
runs(len(pat), func(i int) bool { return pat[i] == '#' }, func(i, j int) { got = append(got, [2]int{i, j}) })
if !slices.Equal(got, want) {
t.Errorf("runs(%q) = %v, want %v", pat, got, want)
}
}
}
func TestTextLinesEllipsis(t *testing.T) {
cases := []struct {
s string
width, n int
want []string
}{
{"one\ntwo\nthree", 0, 2, []string{"one", "two…"}},
{"one\ntwo", 0, 2, []string{"one", "two"}},
{"hello world again", Measure("hello…"), 1, []string{"hello…"}},
{"hello world again", Measure("hel…"), 1, []string{"hel…"}},
{"像素风格组件库", 36, 2, []string{"像素风", "格组…"}},
}
for _, c := range cases {
if got := textLines(c.s, c.width, c.n); !slices.Equal(got, c.want) {
t.Errorf("textLines(%q, %d, %d) = %q, want %q", c.s, c.width, c.n, got, c.want)
}
}
}
type fakePainter struct{ fills int }
func (f *fakePainter) Fill(ui.Rect, ui.Color, float32) { f.fills++ }
func (f *fakePainter) After(time.Duration) {}
func (f *fakePainter) Now() time.Time { return time.Unix(0, 0) }
func TestDrawingMergesRuns(t *testing.T) {
sp := MustSprite("aab\n.bb")
cases := []struct {
name string
draw func(g Canvas)
want int
}{
{"text A", func(g Canvas) { g.Text(0, 0, "A", Ink, 1) }, 15},
{"text A ×2", func(g Canvas) { g.Text(0, 0, "A", Ink, 2) }, 15},
{"micro 1", func(g Canvas) { g.Micro(0, 0, "1", Ink, 1) }, 5},
{"sprite", func(g Canvas) { g.Sprite(0, 0, sp, 0, Ink, 1) }, 3},
{"flat line", func(g Canvas) { g.Line(0, 0, 9, 0, Ink) }, 1},
{"steep line", func(g Canvas) { g.Line(0, 0, 0, 9, Ink) }, 1},
{"diagonal", func(g Canvas) { g.Line(0, 0, 9, 9, Ink) }, 10},
{"circle 3", func(g Canvas) { g.Circle(3, 3, 3, Ink) }, 12},
{"disc 3", func(g Canvas) { g.Disc(3, 3, 3, Ink) }, 7},
{"dither 0", func(g Canvas) { g.Dither(0, 0, 4, 4, Face, Ink, 0) }, 1},
{"dither 8", func(g Canvas) { g.Dither(0, 0, 4, 4, Face, Ink, 8) }, 9},
{"dither 16", func(g Canvas) { g.Dither(0, 0, 4, 4, Face, Ink, 16) }, 1},
}
for _, c := range cases {
f := &fakePainter{}
c.draw(Canvas{p: f, k: Kit{pal: SpringDay, dot: 1}, W: 32, H: 32})
if f.fills != c.want {
t.Errorf("%s: %d fills, want %d", c.name, f.fills, c.want)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
package pixel_test
import (
"image"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// dots reads a Dot 2 frame, dot by dot.
type dots struct {
t *testing.T
img *image.RGBA
pal *pixel.Palette
}
// at is the slot of dot x, y.
func (d dots) at(x, y int) pixel.Slot {
d.t.Helper()
c := d.img.RGBAAt(2*x, 2*y)
for _, o := range [][2]int{{1, 0}, {0, 1}, {1, 1}} {
if d.img.RGBAAt(2*x+o[0], 2*y+o[1]) != c {
d.t.Fatalf("dot %d,%d is not one color", x, y)
}
}
for s, pc := range d.pal.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B && c.A == 255 {
return pixel.Slot(s)
}
}
d.t.Fatalf("dot %d,%d has %v, outside the palette", x, y, c)
return 0
}
// count is how many dots of an area hold s.
func (d dots) count(x, y, w, h int, s pixel.Slot) int {
n := 0
for j := range h {
for i := range w {
if d.at(x+i, y+j) == s {
n++
}
}
}
return n
}
// canvas draws on a w×h dot box at the top-left.
func canvas(t *testing.T, w, h int, draw func(g pixel.Canvas)) dots {
t.Helper()
pal := pixel.SummerDay
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pal, Dot: 2})
k := pixel.K(c)
ui.Box(c).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { draw(k.Canvas(p, r)) })
}, 2*w, 2*h)
return dots{t: t, img: tt.Image(), pal: pal}
}
+10 -10
View File
@@ -11,12 +11,12 @@ import (
type Slot uint8
const (
Back Slot = iota
Ink Slot = iota
Back
Face
Lit
Shade
Edge
Ink
Faint
Well
Accent
@@ -50,14 +50,14 @@ type Palette struct {
func (p *Palette) C(s Slot) ui.Color { return p.Colors[s] }
var (
SpringDay = palette("Spring Day", "春·昼", Spring, false, "F4EEDC E4DCC6 FFFBEF B3A68C 3A2A3E 33283A 6F6358 FFF6E6 B8395C F2A7B9 FFFFFF 4F9A3F D9982A C73A3A 3F7FCB 8C5BC4")
SpringNight = palette("Spring Night", "春·夜", Spring, true, "1C1730 2E2850 4C4478 1A1530 E8DFF7 F3EEFF A79DC4 14102A F07EA0 FFC2D2 120E24 7CC66A F2C14E F0605F 6FA8F0 B58AF0")
SummerDay = palette("Summer Day", "夏·昼", Summer, false, "F7F3E3 DCEBEE FFFFFF 98B3B8 1D3640 15303B 4F6870 F4FCFF 0E6E8C 7FD0E3 FFFFF6 3A9A4A E0A21A D14836 2F7FD0 9A57C8")
SummerNight = palette("Summer Night", "夏·夜", Summer, true, "0B1630 172A55 2C4A86 0A1328 DDEBFF EAF3FF 98ACCF 08102A 36C2E0 A8EEFA 050C1E B8E35A F7D04A F26A5A 5FA8FF C58CFF")
AutumnDay = palette("Autumn Day", "秋·昼", Autumn, false, "F2E6D0 E6D2B0 FFF6E2 B08F62 3A2416 33200F 6E5640 FFF1D8 B94A1C F0A060 FFFFFF 6E8F2E C98A12 B8302A 3F78B0 8E4E9E")
AutumnNight = palette("Autumn Night", "秋·夜", Autumn, true, "1E1414 33221E 5A3D30 170F0E F1DCC4 FBEBD8 B39A86 140C0B F09A3A FFD08A 0E0808 A8C25A F6C65A F0624E 79A8E0 C88AD8")
WinterDay = palette("Winter Day", "冬·昼", Winter, false, "EEF3F8 D8E2EC FFFFFF 98A9BC 1C2A3C 17243A 52637A F6FAFF 1F5FA8 9CC8F0 FCFEFF 3C8C6C B98A1E B23A48 2A86A8 7656B8")
WinterNight = palette("Winter Night", "冬·夜", Winter, true, "0A1020 18233F 304674 080C1A E2F2FF EEF7FF 9DB2CF 060A16 7FD8F0 CFF4FF 040814 6FE0A8 F5D47A F07A8A 6FB0FF A994F5")
SpringDay = palette("Spring Day", "春·昼", Spring, false, "33283A F4EEDC E4DCC6 FFFBEF B3A68C 3A2A3E 6F6358 FFF6E6 B8395C F2A7B9 FFFFFF 4F9A3F D9982A C73A3A 3F7FCB 8C5BC4")
SpringNight = palette("Spring Night", "春·夜", Spring, true, "F3EEFF 1C1730 2E2850 4C4478 1A1530 E8DFF7 A79DC4 14102A F07EA0 FFC2D2 120E24 7CC66A F2C14E F0605F 6FA8F0 B58AF0")
SummerDay = palette("Summer Day", "夏·昼", Summer, false, "15303B F7F3E3 DCEBEE FFFFFF 98B3B8 1D3640 4F6870 F4FCFF 0E6E8C 7FD0E3 FFFFF6 3A9A4A E0A21A D14836 2F7FD0 9A57C8")
SummerNight = palette("Summer Night", "夏·夜", Summer, true, "EAF3FF 0B1630 172A55 2C4A86 0A1328 DDEBFF 98ACCF 08102A 36C2E0 A8EEFA 050C1E B8E35A F7D04A F26A5A 5FA8FF C58CFF")
AutumnDay = palette("Autumn Day", "秋·昼", Autumn, false, "33200F F2E6D0 E6D2B0 FFF6E2 B08F62 3A2416 6E5640 FFF1D8 B94A1C F0A060 FFFFFF 6E8F2E C98A12 B8302A 3F78B0 8E4E9E")
AutumnNight = palette("Autumn Night", "秋·夜", Autumn, true, "FBEBD8 1E1414 33221E 5A3D30 170F0E F1DCC4 B39A86 140C0B F09A3A FFD08A 0E0808 A8C25A F6C65A F0624E 79A8E0 C88AD8")
WinterDay = palette("Winter Day", "冬·昼", Winter, false, "17243A EEF3F8 D8E2EC FFFFFF 98A9BC 1C2A3C 52637A F6FAFF 1F5FA8 9CC8F0 FCFEFF 3C8C6C B98A1E B23A48 2A86A8 7656B8")
WinterNight = palette("Winter Night", "冬·夜", Winter, true, "EEF7FF 0A1020 18233F 304674 080C1A E2F2FF 9DB2CF 060A16 7FD8F0 CFF4FF 040814 6FE0A8 F5D47A F07A8A 6FB0FF A994F5")
)
var Palettes = []*Palette{SpringDay, SpringNight, SummerDay, SummerNight, AutumnDay, AutumnNight, WinterDay, WinterNight}
+2 -1
View File
@@ -30,6 +30,7 @@ type Kit struct {
dot float32
locale Locale
now time.Time
clock func() time.Time
reduce bool
}
@@ -80,7 +81,7 @@ func K(c *ui.Context) Kit {
if s.Now != nil {
now = s.Now()
}
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, reduce: c.Preferences().ReduceMotion}
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, clock: s.Now, reduce: c.Preferences().ReduceMotion}
}
func frameOf(c *ui.Context) *frame {
+112
View File
@@ -0,0 +1,112 @@
package pixel
import (
"fmt"
"strings"
)
// Sprite is hand-placed art in palette slots, in frames.
type Sprite struct {
W, H int
frames [][]int8
}
const (
cellClear int8 = -1
cellInk int8 = -2
)
// Art letters naming slots; '.' is clear, '#' the ink.
const artSlots = "kbflsenwaAigyrcm"
// MustSprite parses string art; blank lines split frames.
func MustSprite(art string) *Sprite {
var frames [][]string
var cur []string
for _, line := range strings.Split(strings.TrimSpace(art), "\n") {
line = strings.TrimSpace(line)
if line == "" {
if cur != nil {
frames, cur = append(frames, cur), nil
}
continue
}
cur = append(cur, line)
}
if cur == nil {
panic("pixel: empty sprite")
}
frames = append(frames, cur)
sp := &Sprite{W: len(frames[0][0]), H: len(frames[0])}
for _, f := range frames {
if len(f) != sp.H {
panic(fmt.Sprintf("pixel: sprite frames of %d and %d rows", sp.H, len(f)))
}
px := make([]int8, 0, sp.W*sp.H)
for _, row := range f {
if len(row) != sp.W {
panic(fmt.Sprintf("pixel: sprite row %q is not %d wide", row, sp.W))
}
for i := range len(row) {
px = append(px, cell(row[i]))
}
}
sp.frames = append(sp.frames, px)
}
return sp
}
func cell(c byte) int8 {
switch c {
case '.':
return cellClear
case '#':
return cellInk
}
if i := strings.IndexByte(artSlots, c); i >= 0 {
return int8(i)
}
panic(fmt.Sprintf("pixel: sprite character %q", c))
}
func (s *Sprite) Frames() int { return len(s.frames) }
// Mirror returns s flipped left to right.
func (s *Sprite) Mirror() *Sprite {
m := &Sprite{W: s.W, H: s.H}
for _, px := range s.frames {
out := make([]int8, len(px))
for y := range s.H {
for x := range s.W {
out[y*s.W+x] = px[y*s.W+s.W-1-x]
}
}
m.frames = append(m.frames, out)
}
return m
}
// Sprite draws frame of sp; # takes ink; frames wrap.
func (g Canvas) Sprite(x, y int, sp *Sprite, frame int, ink Slot, scale int) {
checkScale(scale)
n := len(sp.frames)
px := sp.frames[(frame%n+n)%n]
for row := range sp.H {
line := px[row*sp.W : (row+1)*sp.W]
for col := 0; col < sp.W; {
v := line[col]
end := col + 1
for end < sp.W && line[end] == v {
end++
}
if v != cellClear {
s := ink
if v >= 0 {
s = Slot(v)
}
g.Rect(x+col*scale, y+row*scale, (end-col)*scale, scale, s)
}
col = end
}
}
}