diff --git a/image.go b/image.go new file mode 100644 index 0000000..bd7d0fc --- /dev/null +++ b/image.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 the go-widgets/painter authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package painter + +// ImagePainter is an optional Painter capability: it puts a block of RGBA +// pixels on the surface in one call. +// +// The base interface can draw rectangles, rounded rectangles, text and single +// pixels — and nothing that carries pixels of its own. Every widget showing an +// image therefore had to spell it out one pixel at a time: the toolkit's Image, +// Thumbnail, Wallpaper, Browser, ColorPicker and both font paths all loop over +// the destination calling PutPixel, which is an interface call per pixel — +// about 700,000 of them for a full 1000x700 window. Applications with their own +// framebuffer went further and bypassed the painter completely, reaching for +// the raw buffer, which is exactly what stops them being hosted by a back-end +// that hands out a Painter and nothing else. +// +// DrawImage scales src (srcW x srcH, 4 bytes per pixel, RGBA) into dst by +// nearest-neighbour sampling — the same mapping the hand-written loops used, so +// output is unchanged — and honours the active clip and translation like every +// other primitive. +// +// A src that is too short for srcW*srcH*4 is ignored rather than read past its +// end: the caller has a bug, and a painter is the wrong place to panic. +type ImagePainter interface { + DrawImage(dst Rect, src []byte, srcW, srcH int) +} + +// DrawImage blits src into dst. Implements ImagePainter. +// +// The fast path is one row at a time. When the destination is the same width as +// the source and the row is fully opaque and unclipped, the row is copied +// wholesale; otherwise each pixel is composited through the same blend the rest +// of the painter uses, so translucent images look identical to the per-PutPixel +// version that came before. +func (p *PixelPainter) DrawImage(dst Rect, src []byte, srcW, srcH int) { + if srcW <= 0 || srcH <= 0 || dst.W <= 0 || dst.H <= 0 { + return + } + if len(src) < srcW*srcH*4 { + return + } + dst = shiftRect(p.off, dst) + + // A row can be copied wholesale when nothing has to be decided per pixel: + // same width as the source, entirely on the surface, no clip in force, and + // fully opaque. Scanning the alphas to find that out costs a quarter of the + // copy and saves the blend on every pixel of the row. + rowCopyable := dst.W == srcW && dst.X >= 0 && dst.X+dst.W <= p.Width && len(p.clip) == 0 + + for dy := 0; dy < dst.H; dy++ { + y := dst.Y + dy + if y < 0 || y >= p.Height { + continue + } + sy := dy * srcH / dst.H + srcRow := sy * srcW * 4 + dstRow := y * p.Width * 4 + + // The buffer bound is checked here and not with the rest of the + // condition because a caller may hand over a Buf shorter than + // Width*Height*4, exactly as PutPixel tolerates; the fast path must not + // be the one place that panics on it. + if end := dstRow + (dst.X+dst.W)*4; rowCopyable && end <= len(p.Buf) && + rowOpaque(src[srcRow:srcRow+srcW*4]) { + copy(p.Buf[dstRow+dst.X*4:end], src[srcRow:srcRow+srcW*4]) + continue + } + + for dx := 0; dx < dst.W; dx++ { + x := dst.X + dx + if x < 0 || x >= p.Width { + continue + } + if !clipAllows(p.clip, x, y) { + continue + } + sOff := srcRow + (dx*srcW/dst.W)*4 + dOff := dstRow + x*4 + if dOff < 0 || dOff+3 >= len(p.Buf) { + continue + } + if a := src[sOff+3]; a == 0xFF { + copy(p.Buf[dOff:dOff+4], src[sOff:sOff+4]) + } else if a != 0 { + p.blendInto(dOff, RGBA{ + R: src[sOff], G: src[sOff+1], B: src[sOff+2], A: a, + }) + } + } + } +} + +// DrawImage maps the image onto the cell grid: each cell takes the colour of +// the source pixel it lands on, as a full-block glyph — which is what +// CellPainter.PutPixel already means by a pixel. Implements ImagePainter. +// +// A terminal cannot show an image, so this is the same honest degradation +// PutPixel already makes — a coloured cell rather than nothing at all. +func (p *CellPainter) DrawImage(dst Rect, src []byte, srcW, srcH int) { + if srcW <= 0 || srcH <= 0 || dst.W <= 0 || dst.H <= 0 { + return + } + if len(src) < srcW*srcH*4 { + return + } + for dy := 0; dy < dst.H; dy++ { + sy := dy * srcH / dst.H + for dx := 0; dx < dst.W; dx++ { + sOff := (sy*srcW + dx*srcW/dst.W) * 4 + c := RGBA{R: src[sOff], G: src[sOff+1], B: src[sOff+2], A: src[sOff+3]} + if c.A == 0 { + continue + } + // PutPixel already promotes a pixel to a filled cell and applies + // the clip and translation; going through it keeps one definition + // of what a pixel means on a grid. + p.PutPixel(dst.X+dx, dst.Y+dy, c) + } + } +} + +// rowOpaque reports whether every pixel of an RGBA row is fully opaque, which +// is what makes a wholesale copy equivalent to compositing it. +func rowOpaque(row []byte) bool { + for i := 3; i < len(row); i += 4 { + if row[i] != 0xFF { + return false + } + } + return true +} diff --git a/image_test.go b/image_test.go new file mode 100644 index 0000000..09e379a --- /dev/null +++ b/image_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 the go-widgets/painter authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package painter + +import "testing" + +// srcImage builds a w*h RGBA block whose red channel encodes the column and +// green the row, so a test can tell exactly which source pixel landed where. +func srcImage(w, h int) []byte { + b := make([]byte, w*h*4) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + o := (y*w + x) * 4 + b[o], b[o+1], b[o+2], b[o+3] = uint8(x), uint8(y), 0, 0xFF + } + } + return b +} + +func pixAt(p *PixelPainter, x, y int) RGBA { + o := (y*p.Width + x) * 4 + return RGBA{R: p.Buf[o], G: p.Buf[o+1], B: p.Buf[o+2], A: p.Buf[o+3]} +} + +// A 1:1 blit puts every source pixel exactly where it belongs. +func TestDrawImageOneToOne(t *testing.T) { + p := newPix(8, 8) + p.DrawImage(Rect{X: 2, Y: 3, W: 4, H: 4}, srcImage(4, 4), 4, 4) + + if got := pixAt(p, 2, 3); got.R != 0 || got.G != 0 { + t.Errorf("top-left = %+v, want source 0,0", got) + } + if got := pixAt(p, 5, 6); got.R != 3 || got.G != 3 { + t.Errorf("bottom-right = %+v, want source 3,3", got) + } + if pixAt(p, 1, 3).A != 0 { + t.Error("painted left of the destination") + } +} + +// Scaling uses the same nearest-neighbour mapping the hand-written loops did, +// so widgets that move onto this look identical. +func TestDrawImageScales(t *testing.T) { + p := newPix(8, 8) + p.DrawImage(Rect{X: 0, Y: 0, W: 4, H: 4}, srcImage(2, 2), 2, 2) + + if got := pixAt(p, 0, 0); got.R != 0 { + t.Errorf("0,0 = %+v, want source column 0", got) + } + if got := pixAt(p, 3, 3); got.R != 1 || got.G != 1 { + t.Errorf("3,3 = %+v, want source 1,1", got) + } +} + +// The clip and the translation apply to a blit exactly as to every other +// primitive — a viewport must be able to hold an image. +func TestDrawImageHonoursClipAndTranslation(t *testing.T) { + p := newPix(10, 10) + p.PushTranslate(2, 2) + p.PushClip(Rect{X: 0, Y: 0, W: 2, H: 2}) + p.DrawImage(Rect{X: 0, Y: 0, W: 4, H: 4}, srcImage(4, 4), 4, 4) + p.PopClip() + p.PopTranslate() + + if pixAt(p, 2, 2).A == 0 { + t.Error("the clipped area was not painted") + } + if pixAt(p, 5, 5).A != 0 { + t.Error("the blit escaped a clip that moved with the translation") + } + if pixAt(p, 0, 0).A != 0 { + t.Error("painted at the untranslated origin") + } +} + +// Translucent pixels composite through the same blend the rest of the painter +// uses, so an image over a background looks the way it did pixel by pixel. +func TestDrawImageBlendsAlpha(t *testing.T) { + p := newPix(2, 2) + p.FillRect(Rect{X: 0, Y: 0, W: 2, H: 2}, RGBA{R: 0, G: 0, B: 0, A: 255}) + half := []byte{255, 255, 255, 128} + p.DrawImage(Rect{X: 0, Y: 0, W: 1, H: 1}, half, 1, 1) + + if got := pixAt(p, 0, 0); got.R == 0 || got.R == 255 { + t.Errorf("blended pixel = %+v, want a mid grey", got) + } +} + +// A caller with a bug must not take the painter down with it, and must not read +// past the end of its own slice. +func TestDrawImageRejectsNonsense(t *testing.T) { + p := newPix(4, 4) + p.DrawImage(Rect{X: 0, Y: 0, W: 2, H: 2}, srcImage(2, 2), 0, 2) // no source width + p.DrawImage(Rect{X: 0, Y: 0, W: 0, H: 2}, srcImage(2, 2), 2, 2) // empty destination + p.DrawImage(Rect{X: 0, Y: 0, W: 2, H: 2}, []byte{1, 2, 3}, 2, 2) // source too short + for i := range p.Buf { + if p.Buf[i] != 0 { + t.Fatal("a malformed call painted something") + } + } +} + +// Off-surface destinations are clipped rather than dropped whole: the visible +// part still lands. +func TestDrawImagePartiallyOffSurface(t *testing.T) { + p := newPix(4, 4) + p.DrawImage(Rect{X: -1, Y: -1, W: 3, H: 3}, srcImage(3, 3), 3, 3) + if pixAt(p, 0, 0).A == 0 { + t.Error("the on-surface part of the blit was dropped") + } +} + +// The cell back-end degrades to coloured cells, the same promotion PutPixel +// already makes, so a terminal shows something rather than nothing. +func TestCellPainterDrawImage(t *testing.T) { + // A pixel on a grid is a full-block glyph in the FOREGROUND — that is what + // CellPainter.PutPixel already means by one — so that is what to assert. + c := &CellPainter{Cells: make([]Cell, 4*4), W: 4, H: 4} + c.DrawImage(Rect{X: 1, Y: 1, W: 2, H: 2}, srcImage(2, 2), 2, 2) + if c.Cells[1*4+1].Fg.A == 0 { + t.Errorf("cell 1,1 = %+v, want a filled block", c.Cells[1*4+1]) + } + if c.Cells[0].Fg.A != 0 { + t.Error("painted outside the destination") + } + // Fully transparent source pixels leave the grid alone. + c2 := &CellPainter{Cells: make([]Cell, 4*4), W: 4, H: 4} + c2.DrawImage(Rect{X: 0, Y: 0, W: 1, H: 1}, []byte{9, 9, 9, 0}, 1, 1) + if c2.Cells[0].Fg.A != 0 { + t.Error("a transparent pixel filled a cell") + } + c2.DrawImage(Rect{X: 0, Y: 0, W: 1, H: 1}, []byte{1, 2, 3}, 1, 1) // too short + c2.DrawImage(Rect{X: 0, Y: 0, W: 0, H: 1}, srcImage(1, 1), 1, 1) // empty +} + +// The row-copy fast path: same width as the source, on the surface, unclipped +// and opaque. It must produce exactly what the per-pixel path produces — the +// speed is worthless if the picture differs. +func TestDrawImageRowCopyMatchesPerPixel(t *testing.T) { + src := srcImage(16, 4) + + fast := newPix(16, 4) + fast.DrawImage(Rect{X: 0, Y: 0, W: 16, H: 4}, src, 16, 4) + + // The same blit with a clip covering everything takes the per-pixel path. + slow := newPix(16, 4) + slow.PushClip(Rect{X: 0, Y: 0, W: 16, H: 4}) + slow.DrawImage(Rect{X: 0, Y: 0, W: 16, H: 4}, src, 16, 4) + slow.PopClip() + + for i := range fast.Buf { + if fast.Buf[i] != slow.Buf[i] { + t.Fatalf("byte %d differs: fast %d, per-pixel %d", i, fast.Buf[i], slow.Buf[i]) + } + } +} + +// A row that is not fully opaque falls out of the fast path and composites, +// so a translucent band over a background still blends. +func TestDrawImageRowWithAlphaFallsOutOfTheFastPath(t *testing.T) { + p := newPix(2, 1) + p.FillRect(Rect{X: 0, Y: 0, W: 2, H: 1}, RGBA{A: 255}) + src := []byte{255, 255, 255, 255, 255, 255, 255, 128} + p.DrawImage(Rect{X: 0, Y: 0, W: 2, H: 1}, src, 2, 1) + + if got := pixAt(p, 0, 0); got.R != 255 { + t.Errorf("opaque pixel = %+v, want white", got) + } + if got := pixAt(p, 1, 0); got.R == 0 || got.R == 255 { + t.Errorf("translucent pixel = %+v, want a blend", got) + } +} + +// A Buf shorter than Width*Height*4 is tolerated rather than fatal, the same +// way PutPixel tolerates it: the blit fills what exists and stops. Both the +// row-copy path and the per-pixel path have to survive it. +func TestDrawImageShortBuffer(t *testing.T) { + src := srcImage(4, 4) + + // Row copy: the last rows fall outside the truncated buffer. + short := &PixelPainter{Buf: make([]byte, 4*2*4), Width: 4, Height: 4} + short.DrawImage(Rect{X: 0, Y: 0, W: 4, H: 4}, src, 4, 4) + if short.Buf[3] == 0 { + t.Error("the rows that did fit were not painted") + } + + // Per-pixel: a clip forces the slow path over the same short buffer. + short2 := &PixelPainter{Buf: make([]byte, 4*2*4), Width: 4, Height: 4} + short2.PushClip(Rect{X: 0, Y: 0, W: 4, H: 4}) + short2.DrawImage(Rect{X: 0, Y: 0, W: 4, H: 4}, src, 4, 4) + short2.PopClip() + if short2.Buf[3] == 0 { + t.Error("the rows that did fit were not painted on the clipped path") + } +} + +// Both painters satisfy the capability, which is what widgets type-assert for. +func TestPaintersImplementImagePainter(t *testing.T) { + var _ ImagePainter = (*PixelPainter)(nil) + var _ ImagePainter = (*CellPainter)(nil) +} + +// The measurement that justifies the primitive: the same blit, once through +// DrawImage and once the way every widget had to write it before. +func BenchmarkDrawImage(b *testing.B) { + p := newPix(1000, 700) + src := srcImage(1000, 700) + dst := Rect{X: 0, Y: 0, W: 1000, H: 700} + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.DrawImage(dst, src, 1000, 700) + } +} + +func BenchmarkPerPixelBlit(b *testing.B) { + p := newPix(1000, 700) + src := srcImage(1000, 700) + var q Painter = p + b.ResetTimer() + for i := 0; i < b.N; i++ { + for dy := 0; dy < 700; dy++ { + for dx := 0; dx < 1000; dx++ { + o := (dy*1000 + dx) * 4 + q.PutPixel(dx, dy, RGBA{R: src[o], G: src[o+1], B: src[o+2], A: src[o+3]}) + } + } + } +}