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
19 changes: 18 additions & 1 deletion cell.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,27 @@ type CellPainter struct {

// clip confines writes to the top rect (empty = whole grid). See Clipper.
clip []Rect

// off is the active translation stack; the top is added to every
// coordinate before it is clipped or written. Managed via
// PushTranslate / PopTranslate — see Translator.
off []offset
}

// PushClip confines subsequent writes to r (intersected with any enclosing
// clip). Implements Clipper.
func (p *CellPainter) PushClip(r Rect) { p.clip = pushClip(p.clip, r) }
func (p *CellPainter) PushClip(r Rect) { p.clip = pushClip(p.clip, shiftRect(p.off, r)) }

// PopClip removes the most recent PushClip. Implements Clipper.
func (p *CellPainter) PopClip() { p.clip = popClip(p.clip) }

// PushTranslate shifts subsequent drawing by dx,dy, on top of any enclosing
// translation. Implements Translator.
func (p *CellPainter) PushTranslate(dx, dy int) { p.off = pushOffset(p.off, dx, dy) }

// PopTranslate removes the most recent PushTranslate. Implements Translator.
func (p *CellPainter) PopTranslate() { p.off = popOffset(p.off) }

// NewCellPainter builds a fresh painter over an allocated grid. The
// grid is initialized to space + black on black — the widget draws
// its own background.
Expand Down Expand Up @@ -127,6 +139,10 @@ func (p *CellPainter) Size() (int, int) { return p.W, p.H }
// set writes a full cell (rune + fg + bg) at (x, y), skipping any
// out-of-bounds coordinate.
func (p *CellPainter) set(x, y int, r rune, fg, bg RGBA) {
// Translated here rather than in each primitive: every write funnels
// through set/setFg, and shifting per public method would apply the
// offset once per layer of composition.
x, y = shiftPoint(p.off, x, y)
if x < 0 || y < 0 || x >= p.W || y >= p.H {
return
}
Expand All @@ -138,6 +154,7 @@ func (p *CellPainter) set(x, y int, r rune, fg, bg RGBA) {

// setFg writes a rune + fg without touching the existing bg.
func (p *CellPainter) setFg(x, y int, r rune, fg RGBA) {
x, y = shiftPoint(p.off, x, y)
if x < 0 || y < 0 || x >= p.W || y >= p.H {
return
}
Expand Down
19 changes: 18 additions & 1 deletion pixel.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type PixelPainter struct {
// means the whole surface. Managed via PushClip / PopClip.
clip []Rect

// off is the active translation stack; the top is added to every
// coordinate before it is clipped or written. Managed via
// PushTranslate / PopTranslate — see Translator.
off []offset

// pathCov / pathTmp / pathXS are reusable rasteriser scratch buffers, grown
// on demand and reused across FillPath / StrokePath calls so a steady stream
// of vector draws amortises to ~zero coverage-buffer allocation. pathCov is
Expand Down Expand Up @@ -64,11 +69,18 @@ func (p *PixelPainter) tmpScratch(n int) []float64 {

// PushClip confines subsequent drawing to r (intersected with any enclosing
// clip). Implements Clipper.
func (p *PixelPainter) PushClip(r Rect) { p.clip = pushClip(p.clip, r) }
func (p *PixelPainter) PushClip(r Rect) { p.clip = pushClip(p.clip, shiftRect(p.off, r)) }

// PopClip removes the most recent PushClip. Implements Clipper.
func (p *PixelPainter) PopClip() { p.clip = popClip(p.clip) }

// PushTranslate shifts subsequent drawing by dx,dy, on top of any enclosing
// translation. Implements Translator.
func (p *PixelPainter) PushTranslate(dx, dy int) { p.off = pushOffset(p.off, dx, dy) }

// PopTranslate removes the most recent PushTranslate. Implements Translator.
func (p *PixelPainter) PopTranslate() { p.off = popOffset(p.off) }

// NewPixelPainter builds a fresh painter over an already-allocated
// buffer. The buffer must be exactly `4*width*height` bytes; a
// mismatch is not policed here (the primitive calls just no-op on
Expand Down Expand Up @@ -123,6 +135,11 @@ func (p *PixelPainter) StrokeRect(r Rect, c RGBA, lineW int) {
// Compositing over an opaque destination yields an opaque result, so a
// surface stays fully opaque for the host compositor.
func (p *PixelPainter) PutPixel(x, y int, c RGBA) {
// The translation is applied HERE, in the one write every primitive
// funnels through — FillRect, StrokeRect and the rounded pair all reach
// the surface by calling this. Shifting in each public method instead
// would apply the offset once per layer of composition.
x, y = shiftPoint(p.off, x, y)
if x < 0 || y < 0 || x >= p.Width || y >= p.Height {
return
}
Expand Down
82 changes: 82 additions & 0 deletions translate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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

// Translator is an optional Painter capability: while a translation is pushed,
// every coordinate handed to the painter is shifted by it before anything is
// clipped or written.
//
// Together with [Clipper] it is a VIEWPORT — the pair a scrolling or panning
// widget needs. Clipping alone was not enough, and the gap had a real cost:
// with no way to say "draw my child 250 pixels higher", [Clipper]'s only
// customer, ScrollView, moved the child's BOUNDS instead, drew, and put them
// back. Geometry that changes for the duration of a paint is invisible to
// anything reading it from outside — a screen reader was told a control sat a
// quarter of a window below where it was painted.
//
// A translation shifts the PAINT, not the widget: a child still lays out and
// reports its bounds wherever it genuinely is, and the viewport decides where
// those pixels land. Nothing has to be moved and restored.
//
// if t, ok := p.(painter.Translator); ok {
// t.PushTranslate(-offsetX, -offsetY)
// defer t.PopTranslate()
// }
// child.Draw(p, theme)
//
// Translations nest: each push adds to the enclosing one, so a scrolled list
// inside a scrolled panel behaves as a reader would expect. A clip pushed while
// a translation is active is translated too, since the caller expresses it in
// the same coordinates as everything else it draws.
//
// Both PixelPainter and CellPainter implement Translator; a back-end that
// cannot translate simply does not, and the assertion is skipped — the same
// contract [Clipper] uses.
type Translator interface {
PushTranslate(dx, dy int)
PopTranslate()
}

// offset is one entry of a translation stack: the ACCUMULATED shift at that
// depth, so reading the current translation is a look at the top rather than a
// walk down the stack.
type offset struct{ dx, dy int }

// pushOffset adds dx,dy to the enclosing translation and returns the new stack.
func pushOffset(s []offset, dx, dy int) []offset {
cur := currentOffset(s)
return append(s, offset{dx: cur.dx + dx, dy: cur.dy + dy})
}

// popOffset removes the innermost translation. Popping an empty stack is a
// no-op rather than a panic: a widget that pops without pushing is confused,
// not dangerous, and a painter is the wrong place to enforce that.
func popOffset(s []offset) []offset {
if len(s) == 0 {
return s
}
return s[:len(s)-1]
}

// currentOffset is the shift in force, or zero when nothing is pushed.
func currentOffset(s []offset) offset {
if len(s) == 0 {
return offset{}
}
return s[len(s)-1]
}

// shiftRect and shiftPoint move a caller's coordinates into surface space.
func shiftRect(s []offset, r Rect) Rect {
o := currentOffset(s)
r.X += o.dx
r.Y += o.dy
return r
}

func shiftPoint(s []offset, x, y int) (int, int) {
o := currentOffset(s)
return x + o.dx, y + o.dy
}
136 changes: 136 additions & 0 deletions translate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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"

func newPix(w, h int) *PixelPainter {
return &PixelPainter{Buf: make([]byte, w*h*4), Width: w, Height: h}
}

func lit(p *PixelPainter, x, y int) bool {
off := (y*p.Width + x) * 4
return p.Buf[off+3] != 0
}

var white = RGBA{R: 255, G: 255, B: 255, A: 255}

// A translation moves the PAINT, not the caller's rectangle: the same
// FillRect lands somewhere else without the caller changing a coordinate.
func TestPushTranslateMovesThePaint(t *testing.T) {
p := newPix(20, 20)
p.PushTranslate(5, 7)
p.FillRect(Rect{X: 0, Y: 0, W: 2, H: 2}, white)
p.PopTranslate()

if lit(p, 0, 0) {
t.Error("painted at the untranslated origin")
}
if !lit(p, 5, 7) || !lit(p, 6, 8) {
t.Error("the 2x2 fill did not land at 5,7")
}
if lit(p, 7, 9) {
t.Error("painted beyond the 2x2 fill")
}
}

// Popping restores the enclosing translation, so a widget cannot leak its
// offset onto its siblings.
func TestPopTranslateRestores(t *testing.T) {
p := newPix(20, 20)
p.PushTranslate(5, 5)
p.PopTranslate()
p.PutPixel(1, 1, white)
if !lit(p, 1, 1) {
t.Error("the translation outlived its Pop")
}
}

// Translations nest by adding, so a scrolled list inside a scrolled panel
// behaves the way a reader expects.
func TestTranslationsNest(t *testing.T) {
p := newPix(20, 20)
p.PushTranslate(3, 0)
p.PushTranslate(4, 2)
p.PutPixel(0, 0, white)
p.PopTranslate()
p.PutPixel(0, 0, white)
p.PopTranslate()

if !lit(p, 7, 2) {
t.Error("the inner translation did not accumulate onto the outer one")
}
if !lit(p, 3, 0) {
t.Error("popping the inner translation did not return to the outer one")
}
}

// The offset must be applied ONCE however many primitives compose to reach
// the surface: FillRoundRect falls through to FillRect, which calls PutPixel.
// Shifting in each public method instead of at the write would move a rounded
// rectangle three times as far as a pixel.
func TestTranslationAppliedOncePerWrite(t *testing.T) {
p := newPix(30, 30)
p.PushTranslate(10, 10)
p.FillRoundRect(Rect{X: 0, Y: 0, W: 4, H: 4}, 0, white) // radius 0 -> FillRect
p.PopTranslate()

if !lit(p, 10, 10) {
t.Error("the rounded fill did not land at the translated origin")
}
if lit(p, 20, 20) {
t.Error("the offset was applied more than once")
}
}

// A clip pushed while translated is expressed in the caller's coordinates,
// like everything else it draws.
func TestClipIsTranslatedToo(t *testing.T) {
p := newPix(20, 20)
p.PushTranslate(5, 5)
p.PushClip(Rect{X: 0, Y: 0, W: 2, H: 2})
p.FillRect(Rect{X: 0, Y: 0, W: 10, H: 10}, white)
p.PopClip()
p.PopTranslate()

if !lit(p, 5, 5) || !lit(p, 6, 6) {
t.Error("the clipped area was not painted")
}
if lit(p, 7, 7) {
t.Error("painting escaped a clip that should have moved with the translation")
}
}

// Popping more than was pushed is a confused caller, not a crash.
func TestPopTranslateOnEmptyIsSafe(t *testing.T) {
p := newPix(4, 4)
p.PopTranslate()
p.PutPixel(0, 0, white)
if !lit(p, 0, 0) {
t.Error("an unbalanced Pop disturbed the painter")
}
}

// The cell back-end carries the same capability, so a terminal UI scrolls the
// same way a pixel one does.
func TestCellPainterTranslates(t *testing.T) {
c := &CellPainter{Cells: make([]Cell, 10*10), W: 10, H: 10}
c.PushTranslate(2, 3)
c.FillRect(Rect{X: 0, Y: 0, W: 1, H: 1}, white)
c.PopTranslate()

if c.Cells[3*10+2].Bg != white {
t.Errorf("cell 2,3 = %+v, want the fill", c.Cells[3*10+2].Bg)
}
if c.Cells[0].Bg == white {
t.Error("painted at the untranslated origin")
}
}

// Both painters satisfy the capability, which is what widgets type-assert for.
func TestPaintersImplementTranslator(t *testing.T) {
var _ Translator = (*PixelPainter)(nil)
var _ Translator = (*CellPainter)(nil)
}
Loading