Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package art is the primitive layer for ASCII, ANSI and pixel art in a realm.

Readme View source

gno.land/p/moul/art/v0

ASCII, ANSI and pixel art primitives for a realm: hold a picture once, emit it as plain text, as ANSI truecolour, or as SVG, and let the caller pick at render time.

Render returns one string and nothing tells the caller what kind of string it is, so every realm picks markdown and every terminal reader eats the syntax as noise. This package owns the half of that problem that is about pictures.

Two types, seen twice

type is for
Pix a width, a height, one palette index per pixel, a Palette of hex colours what a pixel-art NFT actually is
Canvas a grid of Cell, each a rune plus a foreground and background colour what a terminal actually is

Pix.Canvas(mode) goes from one to the other. Canvas.Text(), Canvas.ANSI() and Canvas.SVG() take it the rest of the way out, and Pix.SVG() skips the grid entirely for art that was never character-shaped.

pix := &art.Pix{W: 8, H: 6, Idx: idx, Pal: art.Palette{"", "#d64550", "#f7a8b0"}}

plain := pix.Canvas(art.Glyphs).Text()        // for a code fence, or a pipe
colour := pix.Canvas(art.HalfBlock).ANSI()    // for a terminal
web := pix.SVG(8).Render("a heart")           // markdown image, data URI, no host

Use Glyphs for sprites, Ramp for photos

The obvious conversion, a luminance ramp, produces mush for pixel art. Two palette entries a sprite might genuinely carry, sand #e8dcc8 and bone #d9d9d9, sit at luminance 221 and 217 out of 255. A ten-step ramp drops both in bucket 8 and the shape between them disappears.

Indexed art already carries its own segmentation, in the palette. So the rule for a sprite is one glyph per palette index, not one glyph per brightness. Ramp is kept because it is right for a photograph, where there is no meaningful palette to key on.

Measured on real art rather than on a fixture picked to show it. Settler #25 (r/g17khq…/settlers/nft:token/25, read from mainnet 2026-09-29, 32x32, CC0) carries 20 palette entries:

keyed on colours that survive into the text
palette index (Glyphs) 20 of 20
luminance (Ramp, ten steps) 9 of 20

Eleven of the artist's twenty colours stop existing, and the shapes between them go with them.

The collisions are not between near-identical shades either, which is the part that is easy to miss: the orange hat (#f08a2a, luminance 152) and the sky-blue tunic (#3f8fd1, luminance 130) fall in the same bucket and come out as the same character. Luminance cannot tell a hue from a hue.

settler_test.gno pins both, and TestRampCollapsesWhatGlyphsKeepsApart pins the mechanism on a two-colour case small enough to read.

The five modes

mode packs colour good for
Glyphs 1 pixel to 1 cell (2 by default, for aspect) optional sprites, and anything that has to survive without colour
Ramp same optional photographs
HalfBlock 2 vertical pixels to 1 cell, with ▀ full the best-looking terminal output
Quadrant 2x2 to 1 cell 2 colours per cell density, at the cost of two colours per block
Braille 2x4 to 1 cell none the densest mode; a pixel is on if its index is not 0

Index 0 is the background by convention: Braille keys on it directly, the default glyph set gives it a space, and a tie in Quadrant's two-colour split goes to the lower index so that ink does not end up in the background.

ANSI is terminal-only, by construction

Canvas.ANSI() emits SGR escapes, which cannot reach gnoweb: p/nt/markdown/sanitize strips control bytes, and raw ESC in a code fence is garbage in HTML anyway.

That is not a gap to close later. It is the reason the target is a parameter rather than a decision. When colour has to survive the web, Canvas.SVG() carries the same two colours per cell through a medium the web can show.

Notes

  • Colours are packed 0xRRGGBB ints, or Default (-1) meaning "whatever the consumer's default is". ParseHex reads #rrggbb and #rgb; #abc expands by duplication to #aabbcc, not by zero-padding.
  • Every helper clips rather than panicking. Art is drawn by arithmetic, and a line one cell off the edge is not worth aborting a Render over.
  • Canvas.Text() trims trailing spaces; Canvas.ANSI() does not, because a trailing cell can carry a background colour and trimming it would punch a hole in the picture.
  • Pix.SVG() emits one <path> per colour, run-length encoded, in pixel coordinates with the scale on the viewBox. On Settler #25 that is 4,532 bytes against 20,625 for one <rect> per run, 4.5x, and scaling up costs 2 bytes total. A realm pays for those bytes in gas and the reader pays in page weight.
  • Canvas.SVG() emits one <text> per non-blank cell rather than one per run: a run would have to assume the font's advance width, and a renderer that disagreed would shear the picture. Dense art belongs in Pix.SVG(), which has no font to get wrong.
  • Text handed to SVG is XML-escaped here, because p/moul/svg's Text interpolates its content into the document unescaped.

Not in this package

The document model. Turning a whole Render into markdown or plain text (links as footnotes, tables with padded columns, headings underlined instead of #-prefixed) is a separate concern and a separate package. This one only knows about pictures.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/p/moul/art/v0 dependency graph

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.

Overview

Package art is the primitive layer for ASCII, ANSI and pixel art in a realm.

A realm's Render returns one string and nothing tells the caller what kind of string it is, so every realm picks markdown and every terminal reader eats the syntax as noise. This package owns the half of that problem that is about pictures: it holds art in a form that can be emitted as plain text, as ANSI truecolour, or as SVG, and lets the caller choose at render time.

Two types, seen twice

Pix is an indexed bitmap: a width, a height, one palette index per pixel, and a Palette of hex colours. It is what a pixel-art NFT actually is.

Canvas is a grid of Cell, each carrying a rune and a foreground and background colour. It is what a terminal actually is.

The conversion between them is the point of the package. Pix.Canvas takes a Mode and produces a Canvas; Canvas.Text, Canvas.ANSI and Canvas.SVG take it the rest of the way out.

Use Glyphs for sprites, Ramp for photos

The obvious conversion, a luminance ramp, produces mush for pixel art.

Measured on Settler #25, a 32x32 sprite on mainnet with 20 palette colours: a ten-step luminance ramp resolves those 20 to 9, so eleven of the artist's colours stop existing. The collisions are not between near-identical shades either. The orange hat (#f08a2a, luminance 152) and the sky-blue tunic (#3f8fd1, luminance 130) land in the same bucket and come out as the same character, because luminance cannot tell a hue from a hue.

Indexed art already carries its own segmentation, in the palette. So the right rule for a sprite is one glyph per palette index (Glyphs), not one glyph per brightness (Ramp). Ramp is kept because it is right for a photograph, where there is no meaningful palette to key on.

ANSI is a terminal-only target, by construction

Canvas.ANSI emits SGR escape sequences. Those cannot reach gnoweb: raw ESC in a code fence is garbage in HTML, and gno.land/p/nt/markdown/sanitize strips control bytes anyway. That is not a gap to close later, it is the reason the target is a parameter instead of a decision. When colour has to survive the web, use Canvas.SVG or Pix.SVG, which carry the same colours through a medium gnoweb can actually show.

Colours are packed ints

A colour is an int holding 0xRRGGBB, or Default (-1) meaning "whatever the consumer's default is": the terminal's default foreground for an FG, and transparent for a background in SVG. ParseHex turns "#rrggbb" or "#rgb" into one.

Constants 4

const DefaultCellW, DefaultCellH, DefaultFont

1const (
2	DefaultCellW = 8
3	DefaultCellH = 16
4	DefaultFont  = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
5)
source

Defaults for SVGOpts. 8x16 is the usual terminal cell ratio, so art laid out for a terminal keeps its proportions.

const Default

1const Default = -1
source

Default is the colour that means "let the consumer decide": the terminal's own foreground or background in ANSI, and transparent in SVG.

const Escape

1const Escape = '\x1b'
source

Escape is the byte that starts every SGR sequence. Exported so a consumer can strip ANSI without hard-coding "\x1b".

const Glyphs, Ramp, HalfBlock, Quadrant, Braille

 1const (
 2	// Glyphs gives every palette index its own rune. The right default for
 3	// sprites and anything else with a small indexed palette.
 4	Glyphs Mode = iota
 5
 6	// Ramp picks a rune by luminance. Right for a photograph, wrong for a
 7	// sprite, and the package doc has the measurement.
 8	Ramp
 9
10	// HalfBlock packs two vertical pixels into one cell with '▀', foreground
11	// for the top pixel and background for the bottom. Full colour, square
12	// aspect, half the rows. The best-looking mode, and terminal-only.
13	HalfBlock
14
15	// Quadrant packs a 2x2 block into one cell. Twice HalfBlock's density,
16	// but a cell carries only two colours, so a four-colour block loses two.
17	Quadrant
18
19	// Braille packs a 2x4 block into one Braille cell. The densest mode and
20	// the only monochrome one: a pixel is on if its index is not 0.
21	Braille
22)
source

Variables 5

var Light, Heavy, Double, Round, ASCII

1var (
2	Light  = BoxStyle{TL: '┌', TR: '┐', BL: '└', BR: '┘', H: '─', V: '│'}
3	Heavy  = BoxStyle{TL: '┏', TR: '┓', BL: '┗', BR: '┛', H: '━', V: '┃'}
4	Double = BoxStyle{TL: '╔', TR: '╗', BL: '╚', BR: '╝', H: '═', V: '║'}
5	Round  = BoxStyle{TL: '╭', TR: '╮', BL: '╰', BR: '╯', H: '─', V: '│'}
6	ASCII  = BoxStyle{TL: '+', TR: '+', BL: '+', BR: '+', H: '-', V: '|'}
7)
source

The border styles worth having. ASCII is the one that survives a consumer with no Unicode: everything else here is box-drawing, which is fine in a terminal and in a markdown code fence but not in, say, a plain-ASCII log.

var Blank

1var Blank = Cell{R: ' ', FG: Default, BG: Default}
source

Blank is the cell a new canvas is filled with.

var DefaultGlyphs

1var DefaultGlyphs = []rune{' ', '#', '@', '%', '*', '+', '=', '~', '-', ':', '.', 'o', 'O', 'x', 'X', 'w', 'W', 'm', 'M', '8'}
source

DefaultGlyphs is one visually distinct rune per palette index, ordered so that neighbouring indices stay apart on screen. Index 0 is a space, matching the background convention.

A palette longer than this wraps, which is a real collision: pass your own set through Opts when that matters.

var DefaultRamp

1var DefaultRamp = []rune{' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'}
source

DefaultRamp runs darkest to lightest, for a terminal with a dark background. Invert it for a light one.

var Quadrants

1var Quadrants = []rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
source

Quadrants is indexed by a 4-bit mask: bit 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right.

Functions 7

func Hex

1func Hex(c int) string
source

Hex is the inverse of ParseHex. Default renders as the empty string, because there is no hex spelling of "the consumer's default".

func Luminance

1func Luminance(c int) int
source

Luminance returns perceived brightness on 0-255, using the Rec. 709 weights (0.2126 R, 0.7152 G, 0.0722 B) in integer arithmetic. Default is treated as black, so an unset colour ramps to the darkest glyph rather than to a midpoint that would read as real content.

This is the function Ramp keys on, and the package doc explains why keying on it is the wrong default for a sprite.

func ParseHex

1func ParseHex(s string) (int, bool)
source

ParseHex reads "#rrggbb", "#rgb", or either without the leading '#', and reports whether it understood the string. An empty string is not an error but it is not a colour either: it reports Default, false, which is how a palette marks a transparent entry.

func RGB

1func RGB(r, g, b int) int
source

RGB packs three 0-255 components into the 0xRRGGBB form this package uses. Components outside 0-255 are clamped rather than wrapped, so a computed channel cannot silently alias onto a neighbouring one.

func Split

1func Split(c int) (r, g, b int)
source

Split returns the three 0-255 components of a packed colour. Default splits to zeroes, so callers must test for Default before calling it rather than after.

func NewCanvas

1func NewCanvas(w, h int) *Canvas
source

NewCanvas returns a w by h canvas of blank cells. Negative dimensions are treated as zero, so a canvas built from arithmetic that underflowed is empty rather than a panic halfway through a Render.

func NewPix

1func NewPix(w, h int, pal Palette) *Pix
source

NewPix returns a w by h bitmap with every pixel at index 0.

Types 8

type BoxStyle

struct
1type BoxStyle struct {
2	TL, TR, BL, BR rune
3	H, V           rune
4}
source

BoxStyle is the six runes a rectangular border needs.

type Canvas

struct
1type Canvas struct {
2	W, H   int
3	Cells  []Cell
4	FG, BG int
5}
source

Canvas is a fixed grid of cells, addressed (x, y) from the top left.

FG and BG are the pen: the colours the drawing helpers ([Write], [HLine], [Box] and the rest) apply to every cell they touch. They start at Default. Set them with Canvas.Pen; a caller that only wants shapes can ignore them entirely.

Methods on Canvas

func ANSI

method on Canvas
1func (c Canvas) ANSI() string
source

ANSI emits the canvas with SGR truecolour escapes, one reset at the end of every coloured row. Trailing spaces are NOT trimmed here: a trailing cell may carry a background colour, and trimming it would put a hole in the picture.

This output cannot reach gnoweb. See the package doc.

func At

method on Canvas
1func (c *Canvas) At(x, y int) Cell
source

At returns the cell at (x, y), or Blank if that is off the canvas.

func Blit

method on Canvas
1func (c *Canvas) Blit(x, y int, src *Canvas)
source

Blit copies src onto c with src's top left at (x, y), colours and all. Cells landing off the edge are dropped.

func Box

method on Canvas
1func (c *Canvas) Box(x, y, w, h int, s BoxStyle)
source

Box draws a w by h border in the pen's colours, leaving the interior untouched. w or h under 2 draws nothing: there is no border with no inside.

func Fill

method on Canvas
1func (c *Canvas) Fill(cell Cell)
source

Fill sets every cell on the canvas to cell.

func Frame

method on Canvas
1func (c *Canvas) Frame(title string, s BoxStyle) *Canvas
source

Frame returns a NEW canvas two cells wider and taller than c, with c blitted into the middle and a border around it. A non-empty title is written into the top edge starting two cells in, and is truncated rather than allowed to run over the corner.

It returns a new canvas rather than growing this one because a Canvas has a fixed size by design: every index in this package is computed from W, and a resize in place would invalidate any offset a caller was holding.

func HLine

method on Canvas
1func (c *Canvas) HLine(x, y, n int, r rune)
source

HLine draws n runes rightwards from (x, y).

func In

method on Canvas
1func (c *Canvas) In(x, y int) bool
source

In reports whether (x, y) is on the canvas. Every helper in this package clips silently rather than panicking: art is drawn by arithmetic and a one-off-the-edge line is not worth aborting a Render over.

func Pen

method on Canvas
1func (c *Canvas) Pen(fg, bg int) *Canvas
source

Pen sets the colours the drawing helpers will apply, and returns the canvas so calls can be chained.

func Put

method on Canvas
1func (c *Canvas) Put(x, y int, r rune)
source

Put writes one rune in the pen's colours.

func Rect

method on Canvas
1func (c *Canvas) Rect(x, y, w, h int, r rune)
source

Rect fills a w by h area with r.

func SVG

method on Canvas
1func (c Canvas) SVG(o SVGOpts) *svg.Canvas
source

SVG renders the canvas as an SVG document: background colours as run-length rectangles, and one <text> element per non-blank cell, centred in its cell so the grid stays aligned whatever the renderer's idea of a monospace advance width is.

This is how colour reaches gnoweb. Canvas.ANSI cannot: raw ESC bytes are stripped by p/nt/markdown/sanitize and are garbage in HTML regardless. An SVG carries the same two colours per cell through a medium the web can show, which is the whole argument for making the target a parameter.

One element per cell rather than one per run is deliberate: a run of text would have to assume the font's advance width, and a renderer that disagreed would shear the picture. Dense pixel art should go through Pix.SVG instead, which is run-length and has no font to get wrong.

func Set

method on Canvas
1func (c *Canvas) Set(x, y int, cell Cell)
source

Set writes a whole cell, colours included, ignoring the pen.

func String

method on Canvas
1func (c Canvas) String() string
source

String is Canvas.Text, so a canvas can be printed directly.

func Text

method on Canvas
1func (c Canvas) Text() string
source

Text emits the canvas as plain text: runes only, one line per row, trailing spaces trimmed. This is the form that belongs inside a triple-backtick fence, and the form a caller can pipe.

Colours are dropped, which is the whole point: a consumer asking for text has said it cannot show them.

func VLine

method on Canvas
1func (c *Canvas) VLine(x, y, n int, r rune)
source

VLine draws n runes downwards from (x, y).

func Write

method on Canvas
1func (c *Canvas) Write(x, y int, s string)
source

Write draws s left to right from (x, y) in the pen's colours. It does not wrap: anything past the right edge is clipped. A '\n' moves to the next row, back at the starting column, which is what makes a multi-line literal blit the way it reads in source.

type Cell

struct
1type Cell struct {
2	R      rune
3	FG, BG int
4}
source

Cell is one character cell: a rune and its two colours. FG and BG are packed 0xRRGGBB values, or Default.

type Mode

ident
1type Mode int
source

Mode is how a Pix becomes a Canvas. They are not cosmetic variants of each other: they trade resolution, colour and consumer against one another, and the package doc says which to reach for.

type Opts

struct
 1type Opts struct {
 2	Mode Mode
 3
 4	// Glyphs overrides [DefaultGlyphs] for [Glyphs] and [DefaultRamp] for
 5	// [Ramp]. Ignored by the block modes.
 6	Glyphs []rune
 7
 8	// Color carries palette colours into the canvas. The block modes need it
 9	// and set it by default; the glyph modes default to off, because their
10	// whole job is to be readable without colour.
11	Color bool
12
13	// Wide emits each pixel as two cells side by side. A terminal cell is
14	// about twice as tall as it is wide, so a sprite rendered one cell per
15	// pixel comes out squashed to half height. On by default for the glyph
16	// modes; meaningless for the block modes, which correct aspect by packing.
17	Wide bool
18}
source

Opts is the full form of Pix.Canvas, for callers who want to override a mode's defaults.

type Palette

slice
1type Palette []string
source

Palette maps a pixel's index to a colour, as a hex string ("#e8c39e", "#abc", with or without the '#'). An empty entry is transparent: Pix.SVG emits no rectangle for it and the colour modes give it Default.

By convention index 0 is the background. Braille keys on that convention directly, and the default glyph set gives index 0 a space.

Methods on Palette

func Color

method on Palette
1func (p Palette) Color(i uint8) int
source

Color returns the packed colour of a palette index, or Default if the index is out of range or the entry is empty or unparseable.

type Pix

struct
1type Pix struct {
2	W, H int
3	Idx  []uint8
4	Pal  Palette
5}
source

Pix is an indexed bitmap: one palette index per pixel, row-major from the top left. This is the shape pixel art actually has on chain, and the reason Glyphs beats Ramp for it.

Methods on Pix

func At

method on Pix
1func (p *Pix) At(x, y int) uint8
source

At returns the palette index at (x, y), or 0 off the bitmap. Off-bitmap reading as background is what lets the conversions below run past the edge of an odd-sized image without a bounds check at every pixel.

func Canvas

method on Pix
1func (p *Pix) Canvas(m Mode) *Canvas
source

Canvas converts the bitmap using a mode's defaults: Glyphs and Ramp come out wide and monochrome, the block modes come out coloured.

func CanvasOpts

method on Pix
1func (p *Pix) CanvasOpts(o Opts) *Canvas
source

CanvasOpts converts the bitmap with the options spelled out.

func Color

method on Pix
1func (p *Pix) Color(x, y int) int
source

Color returns the packed colour at (x, y).

func SVG

method on Pix
1func (p *Pix) SVG(scale int) *svg.Canvas
source

SVG renders the bitmap as one <path> per palette colour, each path a run-length chain of "M<x> <y>h<w>v1h-<w>z" pixel runs. Transparent palette entries emit nothing.

The path lives in pixel coordinates and scale goes on the canvas as a viewBox, so scaling up costs no extra bytes at all.

One path per colour rather than one <rect> per run is worth the loop: measured on Settler #25 (32x32, 20 colours, read from mainnet 2026-09-29), rectangles came to 20,625 bytes against 3,565 for paths, 5.8x. A realm pays for those bytes in gas and the reader pays for them in page weight, and the settlers realm itself emits paths for the same reason.

Use Canvas.Render or Canvas.String from p/moul/svg to get the markdown image or the raw document.

func Set

method on Pix
1func (p *Pix) Set(x, y int, i uint8)
source

Set writes a palette index, clipping silently off the bitmap.

type SVGOpts

struct
1type SVGOpts struct {
2	CellW, CellH int    // cell size in SVG units; 0 means 8 and 16
3	FG, BG       int    // what a cell's Default resolves to; Default stays unset
4	Font         string // font-family; "" means a monospace stack
5}
source

SVGOpts configures Canvas.SVG. The zero value is usable: every field falls back to the constant beside it.

Imports 2

Source Files 13