Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions image.go
Original file line number Diff line number Diff line change
@@ -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
}
230 changes: 230 additions & 0 deletions image_test.go
Original file line number Diff line number Diff line change
@@ -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]})
}
}
}
}
Loading