diff --git a/pixel/canvas.go b/pixel/canvas.go new file mode 100644 index 0000000..42c6ccb --- /dev/null +++ b/pixel/canvas.go @@ -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 +} diff --git a/pixel/canvas_test.go b/pixel/canvas_test.go new file mode 100644 index 0000000..385bae4 --- /dev/null +++ b/pixel/canvas_test.go @@ -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) + } + } + } +} diff --git a/pixel/clock.go b/pixel/clock.go new file mode 100644 index 0000000..7dc6482 --- /dev/null +++ b/pixel/clock.go @@ -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)) + } +} diff --git a/pixel/dither.go b/pixel/dither.go new file mode 100644 index 0000000..b622ca6 --- /dev/null +++ b/pixel/dither.go @@ -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)) + } +} diff --git a/pixel/element.go b/pixel/element.go new file mode 100644 index 0000000..d50709b --- /dev/null +++ b/pixel/element.go @@ -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) + } +} diff --git a/pixel/element_test.go b/pixel/element_test.go new file mode 100644 index 0000000..973bd2b --- /dev/null +++ b/pixel/element_test.go @@ -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") + } +} diff --git a/pixel/engine_internal_test.go b/pixel/engine_internal_test.go new file mode 100644 index 0000000..8b363b2 --- /dev/null +++ b/pixel/engine_internal_test.go @@ -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) + } + } +} diff --git a/pixel/helpers_test.go b/pixel/helpers_test.go new file mode 100644 index 0000000..bd96fb9 --- /dev/null +++ b/pixel/helpers_test.go @@ -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} +} diff --git a/pixel/palette.go b/pixel/palette.go index 0f88a53..33a6390 100644 --- a/pixel/palette.go +++ b/pixel/palette.go @@ -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} diff --git a/pixel/settings.go b/pixel/settings.go index 6fe1f7f..7f3c528 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -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 { diff --git a/pixel/sprite.go b/pixel/sprite.go new file mode 100644 index 0000000..ab7cb54 --- /dev/null +++ b/pixel/sprite.go @@ -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 + } + } +}