painter

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 9, 2026 License: BSD-3-Clause Imports: 4 Imported by: 0

README

go-widgets/painter

CI pages pkg.go.dev coverage go status license

▶ Live demo: https://go-widgets.github.io/painter/

Prototype of a Painter abstraction that lets a single widget render into three deployment families with the same code:

  • WUI — browser wasm + <canvas> + putImageData (via PixelPainter)
  • GUI — native window (SDL / Ebitengine / image files) (via PixelPainter)
  • TUI — terminal cell grid + 24-bit ANSI (via CellPainter)

Both PixelPainter and CellPainter implement the same 5-primitive Painter interface — widgets never see the back-end.

Why this exists

Today the go-widgets/toolkit widget's Draw takes a ([]byte, w int, h int) — a hard binding to a pixel back-end. Great for the browser and native canvases; unworkable for a terminal grid, where the atom is a cell (rune + fg + bg), not a pixel.

This repo prototypes the redesign so we can answer:

Can we write the same widget once and render it to WUI, GUI, and TUI without conditional-compilation gymnastics?

The 5-primitive interface says yes:

type Painter interface {
    FillRect(r Rect, c RGBA)
    StrokeRect(r Rect, c RGBA, lineW int)
    PutPixel(x, y int, c RGBA)
    Text(x, y int, s string, ink RGBA)
    Size() (w, h int)
}

type Widget interface {
    Draw(p Painter, theme *Theme)
}

A widget's Draw composes only those five calls. The back-end decides whether they land as pixels or cells.

Try it

# WUI/GUI proxy — renders to a PNG the same way a browser canvas
# would consume the RGBA buffer.
go run ./cmd/wui-demo --out demo.png            # light theme
go run ./cmd/wui-demo --out demo.png --theme dark

# TUI — writes 24-bit-ANSI to stdout; point your terminal at it.
go run ./cmd/tui-demo
go run ./cmd/tui-demo --theme dark

# WUI live in a browser — Chromium / Firefox / Safari:
task serve                                      # http://localhost:8091/

All three render the exact same three widgets (Label, two Buttons, ProgressBar) through the exact same widget code in widget.go. The only thing that changes between them is which Painter implementation the widget's Draw sees.

What's in the box

File Contents
painter.go Painter interface, Rect, RGBA, RGB helper
pixel.go PixelPainter — writes into an RGBA []byte buffer
cell.go CellPainter — writes into a []Cell grid + 24-bit-ANSI serializer
font.go Minimal 5×7 bitmap font (uppercase + digits + punct.)
theme.go Theme struct + LightTheme / DarkTheme palettes
widget.go Sample widgets: Button, Label, ProgressBar
cmd/wui-demo Renders widgets to a PNG (WUI / GUI back-end proxy)
cmd/wui-wasm Renders widgets to a browser <canvas> (WUI live demo)
cmd/tui-demo Renders widgets to stdout as 24-bit ANSI (TUI)

Status

Prototype — design validation. ~5-primitive API, 3 widgets, 100 % coverage on library packages, CGO_ENABLED=0, builds on all 6 supported 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x) + GOOS=js GOARCH=wasm.

The API is deliberately small. The next step, if the design proves out, is a wholesale migration of go-widgets/toolkit's widget set to this interface + folding this repo's implementation into the toolkit as its v1.0 rendering path.

Non-goals for the prototype

  • Not a full toolkit — 3 widgets only.
  • No event dispatch — HitTest / OnEvent are out of scope. They come back once the render side is validated.
  • No full font — 5×7 bitmap covers uppercase + digits + a handful of punctuation. A production merge reuses go-widgets/toolkit's full font table.
  • No terminal host loop — cmd/tui-demo just writes ANSI to stdout; it doesn't put the terminal into raw mode or dispatch input.

License

BSD 3-Clause. See LICENSE.

Documentation

Overview

Package painter is a prototype of the "Painter" abstraction that lets a single widget's Draw method target three deployment families:

  • WUI (browser wasm + <canvas> + putImageData) → PixelPainter
  • GUI (native window; SDL2, Ebitengine, image/png export…) → PixelPainter
  • TUI (terminal cell grid + ANSI escape codes) → CellPainter

The current go-widgets/toolkit widgets are hard-bound to a []byte + surfaceW pair — great for pixel back-ends, incompatible with a cell grid. This repo prototypes a redesign around a primitive-set interface:

type Painter interface {
    FillRect(r Rect, c RGBA)
    StrokeRect(r Rect, c RGBA, lineW int)
    Text(x, y int, s string, ink RGBA)
    PutPixel(x, y int, c RGBA)
}

A widget's Draw becomes:

func (b *Button) Draw(p Painter, theme *Theme) {
    r := b.Bounds
    p.FillRect(r, theme.Surface)
    p.StrokeRect(r, theme.Border, 1)
    p.Text(r.X+8, r.Y+8, b.Label, theme.OnSurface)
}

The same widget code renders identically in a browser canvas (PixelPainter), a native window (PixelPainter again — the host consumes the buffer differently), a terminal (CellPainter maps RGBA to ANSI 16-colour, snaps rects to cells + uses box-draw glyphs for strokes), or an SVG snapshot (not shipped in this prototype — see go-widgets/svg).

Status: PROTOTYPE. The API surface is deliberately small (5 primitives). Once validated the full toolkit widget set migrates + the prototype folds into go-widgets/toolkit as its v1.0 rendering path.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Button

type Button struct {
	Bounds  Rect
	Label   string
	Pressed bool
}

Button is the prototype's canonical widget — solid fill + border + centred label. Every real widget in the toolkit follows the same four-line pattern.

func (*Button) Draw

func (b *Button) Draw(p Painter, theme *Theme)

Draw paints the button. A pressed button swaps Surface + Accent to visualise the state.

type Cell

type Cell struct {
	Rune rune
	Fg   RGBA
	Bg   RGBA
}

Cell is one terminal cell — a rune plus a foreground and background colour. Painters serialize cell grids into ANSI escape sequences at render time.

type CellPainter

type CellPainter struct {
	W     int
	H     int
	Cells []Cell
}

CellPainter maps the primitive set onto a fixed-size cell grid. Coordinates are in cells; a rectangle of size (10, 3) is 10 cells wide and 3 cells tall regardless of the terminal's font.

Colour: written to a 24-bit-ANSI-truecolor terminal at flush time. The primitive set carries full RGBA; the terminal handles quantiz- ation. This keeps the widget code identical between pixel + cell back-ends.

func NewCellPainter

func NewCellPainter(w, h int) *CellPainter

NewCellPainter builds a fresh painter over an allocated grid. The grid is initialized to space + black on black — the widget draws its own background.

func (*CellPainter) FillRect

func (p *CellPainter) FillRect(r Rect, c RGBA)

FillRect paints a solid block of cells. The rune stays ' '; only the background colour is set. StrokeRect overlays box characters on top.

func (*CellPainter) FillRoundRect added in v0.1.1

func (p *CellPainter) FillRoundRect(r Rect, radius int, c RGBA)

FillRoundRect can't round on a cell grid (a cell is atomic), so it falls back to a square FillRect -- the rounding is a no-op here.

func (*CellPainter) PutPixel

func (p *CellPainter) PutPixel(x, y int, c RGBA)

PutPixel paints a single cell as a filled block character. Useful for pixel-precise widgets that want to render "dots" on a terminal.

func (*CellPainter) Size

func (p *CellPainter) Size() (int, int)

Size returns width × height in cells.

func (*CellPainter) StrokeRect

func (p *CellPainter) StrokeRect(r Rect, c RGBA, lineW int)

StrokeRect draws a 1-cell-wide box using Unicode box-draw runes. lineW is ignored — a terminal cell is atomic.

func (*CellPainter) StrokeRoundRect added in v0.1.1

func (p *CellPainter) StrokeRoundRect(r Rect, radius int, c RGBA, lineW int)

StrokeRoundRect falls back to the square box-draw StrokeRect on a cell grid.

func (*CellPainter) Text

func (p *CellPainter) Text(x, y int, s string, ink RGBA)

Text writes s as-is starting at (x, y) — one rune per cell. UTF-8 wide characters are not policed at this prototype stage; a produc- tion CellPainter would use golang.org/x/text/width.

func (*CellPainter) WriteANSI

func (p *CellPainter) WriteANSI(w io.Writer) (int, error)

WriteANSI serializes the grid as a single ANSI-encoded string (24-bit truecolor). Each row is prefixed with `\x1b[<y+1>;1H` so it starts at column 1 of its own row regardless of terminal wrap / raw-mode CR handling — the previous `\n`-only terminator broke in raw-mode Terminal.app (ONLCR off): LF alone doesn't reset column, so rows 1..N would be written to wrapped positions and the frame would end up blank except for the last row.

type Label

type Label struct {
	Bounds Rect
	Text   string
}

Label is a static text widget. No fill, no border, no state.

func (*Label) Draw

func (l *Label) Draw(p Painter, theme *Theme)

Draw paints the label ink on the surface's own background — the host is expected to have filled the parent Bounds first.

type Painter

type Painter interface {
	// FillRect paints a solid rectangle.
	FillRect(r Rect, c RGBA)

	// StrokeRect paints a 1-line-wide border around r (no fill).
	// lineW is a hint; back-ends that can't do variable strokes
	// (a cell grid, for instance) ignore it.
	StrokeRect(r Rect, c RGBA, lineW int)

	// FillRoundRect fills r with the corners rounded to the given
	// radius (in painter units). radius is clamped to half the
	// smaller side. Pixel back-ends anti-alias the corners; back-ends
	// that can't round (a cell grid) fall back to a square FillRect.
	FillRoundRect(r Rect, radius int, c RGBA)

	// StrokeRoundRect paints a 1-unit rounded border around r. Like
	// StrokeRect, lineW is a hint; non-rounding back-ends fall back to
	// a square StrokeRect.
	StrokeRoundRect(r Rect, radius int, c RGBA, lineW int)

	// PutPixel paints a single pixel at (x, y). On a CellPainter
	// this promotes to a filled cell.
	PutPixel(x, y int, c RGBA)

	// Text paints ink text starting at (x, y). Font metrics come
	// from the painter's own bitmap (PixelPainter's 5×7 font) or
	// the terminal's own font (CellPainter — 1 cell per rune).
	Text(x, y int, s string, ink RGBA)

	// Size returns the painter's canvas dimensions in painter units
	// (pixels for PixelPainter, cells for CellPainter). A widget
	// that wants to fill the whole surface reads this instead of
	// hard-coding a size.
	Size() (w, h int)
}

Painter is the primitive-set every back-end implements. A widget composes only these calls; the back-end decides how they land on the actual output.

Coordinates are in the painter's OWN unit — pixel for PixelPainter, cell for CellPainter. The widget doesn't need to know which; the host sets the widget's Bounds in the right units before Draw is called.

type PixelPainter

type PixelPainter struct {
	// Buf is the destination RGBA byte slice (4 bytes per pixel).
	// The buffer is written in place; callers own its lifecycle.
	Buf []byte

	// Width is the stride in pixels — number of pixels per row.
	// The buffer's actual byte-stride is Width*4.
	Width int

	// Height is the number of rows.
	Height int
}

PixelPainter writes the primitive set into an RGBA byte buffer — the deployment target for the WUI (browser canvas + putImageData) and GUI (native window that consumes a []byte) families. The buffer + stride mirror the toolkit's current Draw signature; a widget migrated to Painter renders identically to today's toolkit output.

func NewPixelPainter

func NewPixelPainter(buf []byte, width, height int) *PixelPainter

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 out-of-bounds writes).

func (*PixelPainter) FillRect

func (p *PixelPainter) FillRect(r Rect, c RGBA)

FillRect fills r with c. Out-of-bounds bytes are dropped so a widget that ranges past the edge doesn't panic.

func (*PixelPainter) FillRoundRect added in v0.1.1

func (p *PixelPainter) FillRoundRect(r Rect, radius int, c RGBA)

FillRoundRect fills r with corners rounded to radius (in pixels), anti- aliasing the corner edge. radius is clamped to half the smaller side; a radius <= 0 degrades to a plain FillRect. Corner-edge pixels are plotted with fractional alpha, which PutPixel composites onto the destination -- so a rounded button/pill reads as a smooth macOS-style shape rather than a jagged one.

func (*PixelPainter) PutPixel

func (p *PixelPainter) PutPixel(x, y int, c RGBA)

PutPixel writes one RGBA at (x, y). Out-of-bounds writes are silently dropped.

Semi-transparent colours are src-over composited onto the existing pixel, so a theme colour like WhiteSur's borders rgba(0,0,0,0.12) paints as a subtle 12%-black hairline instead of a harsh opaque line. The two common cases stay exact and allocation-free:

  • A == 0xFF (the vast majority of widget paint) overwrites verbatim, so opaque rendering is byte-identical to before.
  • A == 0 (fully transparent) is a no-op.

Compositing over an opaque destination yields an opaque result, so a surface stays fully opaque for the host compositor.

func (*PixelPainter) Size

func (p *PixelPainter) Size() (int, int)

Size returns Width × Height in pixels.

func (*PixelPainter) StrokeRect

func (p *PixelPainter) StrokeRect(r Rect, c RGBA, lineW int)

StrokeRect draws a 1-line-wide border around r. lineW is currently ignored — the pixel back-end can't easily draw thick strokes without antialiasing, which is out of scope for this prototype.

func (*PixelPainter) StrokeRoundRect added in v0.1.1

func (p *PixelPainter) StrokeRoundRect(r Rect, radius int, c RGBA, lineW int)

StrokeRoundRect paints a 1-pixel rounded border around r. The straight runs are crisp 1-px lines; the four corners are an anti-aliased quarter-ring.

func (*PixelPainter) Text

func (p *PixelPainter) Text(x, y int, s string, ink RGBA)

Text paints s at (x, y) using the built-in 5×7 bitmap font (see font.go). Each glyph is 5 columns × 7 rows + 1 pixel of inter- glyph spacing (advance = 6).

type ProgressBar

type ProgressBar struct {
	Bounds Rect
	Value  float64
}

ProgressBar visualises a 0.0..1.0 value as a filled ratio of its bounds. Values outside the range clamp.

func (*ProgressBar) Draw

func (b *ProgressBar) Draw(p Painter, theme *Theme)

Draw paints the empty track + a filled portion sized to Value.

type RGBA

type RGBA struct{ R, G, B, A uint8 }

RGBA is a 32-bit colour value. Painters that can't represent a given RGBA (a cell grid limited to 16 colours, for instance) pick the closest supported value.

func RGB

func RGB(r, g, b uint8) RGBA

RGB constructs an opaque colour with A=0xFF.

type Rect

type Rect struct{ X, Y, W, H int }

Rect is a rectangle in the painter's coordinate system. toolkit aliases its own Rect to this one, so the method set is shared — keep any helpers a consumer might want on it (Contains, etc.) defined here.

func (Rect) Contains

func (r Rect) Contains(px, py int) bool

Contains reports whether (px, py) falls inside r. The right + bottom edges are EXCLUSIVE so a w*h rectangle covers exactly w*h units (pixels for PixelPainter, cells for CellPainter).

type Theme

type Theme struct {
	Background RGBA
	Surface    RGBA
	OnSurface  RGBA
	Border     RGBA
	Accent     RGBA
}

Theme is the palette every widget consults. The prototype ships a minimum viable set — a production merge into toolkit reuses the full go-widgets/toolkit theme struct.

func DarkTheme

func DarkTheme() *Theme

DarkTheme mirrors the go-widgets/toolkit default dark palette.

func LightTheme

func LightTheme() *Theme

LightTheme mirrors the go-widgets/toolkit default light palette.

type Widget

type Widget interface {
	Draw(p Painter, theme *Theme)
}

Widget is what every UI element implements. A widget's Draw only touches the Painter primitives; it never allocates a buffer, never knows whether it's rendering pixels or cells.

Directories

Path Synopsis
cmd
tui-demo command
tui-demo renders the same three widgets as wui-demo, but into a CellPainter, and writes the resulting 24-bit-ANSI stream to stdout.
tui-demo renders the same three widgets as wui-demo, but into a CellPainter, and writes the resulting 24-bit-ANSI stream to stdout.
wui-demo command
wui-demo renders the prototype's three widgets into a PixelPainter and writes the resulting RGBA buffer as a PNG.
wui-demo renders the prototype's three widgets into a PixelPainter and writes the resulting RGBA buffer as a PNG.
wui-wasm command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL