Transaction
F44B89E1F5B3E6…4F6B14AD7733
Block 446,902 · index 0 · indexed
Summary
- Hash
- F44B89E1F5B3E6E656422A82F5A9082D1F2E0D5071A72658D8154F6B14AD7733
- Block
- 446,902
- Size
- 57481 bytes
- Gas used
- 71,176,044 / 160,395,000
- Fee
- 481185ugnot
- Status
- success
Messages
- Attached funds
- 23000000ugnot
- Package
- gno.land/p/moul/art/v0
Arguments · 24
- #1art
- #2README.md
- #3# `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. ```gno 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. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4box.gno
- #5package art // BoxStyle is the six runes a rectangular border needs. type BoxStyle struct { TL, TR, BL, BR rune H, V rune } // 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 ( Light = BoxStyle{TL: '┌', TR: '┐', BL: '└', BR: '┘', H: '─', V: '│'} Heavy = BoxStyle{TL: '┏', TR: '┓', BL: '┗', BR: '┛', H: '━', V: '┃'} Double = BoxStyle{TL: '╔', TR: '╗', BL: '╚', BR: '╝', H: '═', V: '║'} Round = BoxStyle{TL: '╭', TR: '╮', BL: '╰', BR: '╯', H: '─', V: '│'} ASCII = BoxStyle{TL: '+', TR: '+', BL: '+', BR: '+', H: '-', V: '|'} ) // 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 (c *Canvas) Box(x, y, w, h int, s BoxStyle) { if w < 2 || h < 2 { return } c.HLine(x+1, y, w-2, s.H) c.HLine(x+1, y+h-1, w-2, s.H) c.VLine(x, y+1, h-2, s.V) c.VLine(x+w-1, y+1, h-2, s.V) c.Put(x, y, s.TL) c.Put(x+w-1, y, s.TR) c.Put(x, y+h-1, s.BL) c.Put(x+w-1, y+h-1, s.BR) } // 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 (c *Canvas) Frame(title string, s BoxStyle) *Canvas { out := NewCanvas(c.W+2, c.H+2) out.FG, out.BG = c.FG, c.BG out.Box(0, 0, out.W, out.H, s) out.Blit(1, 1, c) if title != "" { // The top edge has W-2 usable cells and the title starts at x=2, so it // gets W-4 of them. Anything less than one cell means no room at all. room := out.W - 4 if room > 0 { out.Write(2, 0, truncRunes(title, room)) } } return out } func truncRunes(s string, n int) string { i := 0 for idx := range s { if i == n { return s[:idx] } i++ } return s }
- #6canvas.gno
- #7package art import "strings" // Cell is one character cell: a rune and its two colours. FG and BG are packed // 0xRRGGBB values, or [Default]. type Cell struct { R rune FG, BG int } // 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. type Canvas struct { W, H int Cells []Cell FG, BG int } // Blank is the cell a new canvas is filled with. var Blank = Cell{R: ' ', FG: Default, BG: Default} // 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 NewCanvas(w, h int) *Canvas { if w < 0 { w = 0 } if h < 0 { h = 0 } cells := make([]Cell, w*h) for i := 0; i < len(cells); i++ { cells[i] = Blank } return &Canvas{W: w, H: h, Cells: cells, FG: Default, BG: Default} } // Pen sets the colours the drawing helpers will apply, and returns the canvas // so calls can be chained. func (c *Canvas) Pen(fg, bg int) *Canvas { c.FG, c.BG = fg, bg return c } // 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 (c *Canvas) In(x, y int) bool { return x >= 0 && y >= 0 && x < c.W && y < c.H } // At returns the cell at (x, y), or [Blank] if that is off the canvas. func (c *Canvas) At(x, y int) Cell { if !c.In(x, y) { return Blank } return c.Cells[y*c.W+x] } // Set writes a whole cell, colours included, ignoring the pen. func (c *Canvas) Set(x, y int, cell Cell) { if !c.In(x, y) { return } c.Cells[y*c.W+x] = cell } // Put writes one rune in the pen's colours. func (c *Canvas) Put(x, y int, r rune) { c.Set(x, y, Cell{R: r, FG: c.FG, BG: c.BG}) } // Fill sets every cell on the canvas to cell. func (c *Canvas) Fill(cell Cell) { for i := 0; i < len(c.Cells); i++ { c.Cells[i] = cell } } // 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. func (c *Canvas) Write(x, y int, s string) { cx, cy := x, y for _, r := range s { if r == '\n' { cx, cy = x, cy+1 continue } c.Put(cx, cy, r) cx++ } } // HLine draws n runes rightwards from (x, y). func (c *Canvas) HLine(x, y, n int, r rune) { for i := 0; i < n; i++ { c.Put(x+i, y, r) } } // VLine draws n runes downwards from (x, y). func (c *Canvas) VLine(x, y, n int, r rune) { for i := 0; i < n; i++ { c.Put(x, y+i, r) } } // Rect fills a w by h area with r. func (c *Canvas) Rect(x, y, w, h int, r rune) { for j := 0; j < h; j++ { c.HLine(x, y+j, w, r) } } // Blit copies src onto c with src's top left at (x, y), colours and all. // Cells landing off the edge are dropped. func (c *Canvas) Blit(x, y int, src *Canvas) { if src == nil { return } for j := 0; j < src.H; j++ { for i := 0; i < src.W; i++ { c.Set(x+i, y+j, src.Cells[j*src.W+i]) } } } // 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 (c Canvas) Text() string { var b strings.Builder for y := 0; y < c.H; y++ { var line strings.Builder for x := 0; x < c.W; x++ { line.WriteRune(c.Cells[y*c.W+x].R) } b.WriteString(strings.TrimRight(line.String(), " ")) if y < c.H-1 { b.WriteString("\n") } } return b.String() } // String is [Canvas.Text], so a canvas can be printed directly. func (c Canvas) String() string { return c.Text() } // 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 (c Canvas) ANSI() string { var b strings.Builder for y := 0; y < c.H; y++ { fg, bg := Default, Default for x := 0; x < c.W; x++ { cl := c.Cells[y*c.W+x] if cl.FG != fg || cl.BG != bg { b.WriteString(sgr(cl.FG, cl.BG)) fg, bg = cl.FG, cl.BG } b.WriteRune(cl.R) } if fg != Default || bg != Default { b.WriteString(reset) } if y < c.H-1 { b.WriteString("\n") } } return b.String() } // Escape is the byte that starts every SGR sequence. Exported so a consumer can // strip ANSI without hard-coding "\x1b". const Escape = '\x1b' const reset = "\x1b[0m" func sgr(fg, bg int) string { var b strings.Builder b.WriteString("\x1b[") if fg == Default { b.WriteString("39") } else { r, g, bl := Split(fg) b.WriteString("38;2;") writeInt(&b, r) b.WriteByte(';') writeInt(&b, g) b.WriteByte(';') writeInt(&b, bl) } b.WriteByte(';') if bg == Default { b.WriteString("49") } else { r, g, bl := Split(bg) b.WriteString("48;2;") writeInt(&b, r) b.WriteByte(';') writeInt(&b, g) b.WriteByte(';') writeInt(&b, bl) } b.WriteByte('m') return b.String() } // writeInt writes a 0-255 value without strconv, which this package would // otherwise import for three call sites. func writeInt(b *strings.Builder, v int) { if v >= 100 { b.WriteByte(byte('0' + v/100)) } if v >= 10 { b.WriteByte(byte('0' + (v/10)%10)) } b.WriteByte(byte('0' + v%10)) }
- #8canvas_test.gno
- #9package art import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) func TestNewCanvasIsBlank(t *testing.T) { c := NewCanvas(3, 2) uassert.Equal(t, 6, len(c.Cells), "cell count") uassert.Equal(t, "\n", c.Text(), "two empty rows, both trimmed to nothing") // Negative dimensions must not panic partway through a Render. empty := NewCanvas(-4, -4) uassert.Equal(t, 0, empty.W, "negative width clamps to 0") uassert.Equal(t, "", empty.Text(), "empty canvas is the empty string") } func TestWriteAndClip(t *testing.T) { cases := []struct { name string x, y int in string want string }{ {"fits", 0, 0, "abc", "abc\n\n"}, {"offset", 1, 1, "xy", "\n xy\n"}, {"clipped at the right edge", 2, 0, "abcde", " abc\n\n"}, {"negative x drops the leading runes", -2, 0, "abcde", "cde\n\n"}, {"newline returns to the start column", 1, 0, "ab\ncd", " ab\n cd\n"}, {"off the bottom is dropped entirely", 0, 9, "abc", "\n\n"}, } for _, tc := range cases { c := NewCanvas(5, 3) c.Write(tc.x, tc.y, tc.in) uassert.Equal(t, tc.want, c.Text(), tc.name) } } func TestTextTrimsTrailingSpaceAndANSIDoesNot(t *testing.T) { c := NewCanvas(4, 1) c.Pen(Default, 0xff0000) c.Put(0, 0, 'x') c.Put(3, 0, ' ') // a trailing space that carries a background colour uassert.Equal(t, "x", c.Text(), "text drops the coloured trailing space") ansi := c.ANSI() uassert.True(t, strings.HasSuffix(ansi, reset), "a coloured row ends in a reset") uassert.True(t, strings.Contains(ansi, "48;2;255;0;0"), "the red background is emitted") // Trimming here would punch a hole in the picture, so the cell survives. uassert.Equal(t, "x ", stripSGR(ansi), "all four cells survive in ansi") } func TestANSIEmitsOneEscapePerChange(t *testing.T) { c := NewCanvas(4, 1) c.Pen(0x00ff00, Default) c.Write(0, 0, "aa") c.Pen(0x0000ff, Default) c.Write(2, 0, "bb") got := c.ANSI() // Two colour changes plus the end-of-row reset, and not one per cell. uassert.Equal(t, 3, strings.Count(got, "\x1b["), "escape count") uassert.Equal(t, "aabb", stripSGR(got), "the runes survive intact") } func TestANSIUncolouredRowHasNoEscapes(t *testing.T) { c := NewCanvas(3, 1) c.Write(0, 0, "abc") uassert.Equal(t, "abc", c.ANSI(), "no colour means no escapes and no reset") } func TestBlit(t *testing.T) { src := NewCanvas(2, 2) src.Write(0, 0, "ab\ncd") dst := NewCanvas(4, 4) dst.Blit(1, 1, src) uassert.Equal(t, "\n ab\n cd\n", dst.Text(), "placed at the offset") // Partly off the edge keeps what fits and drops the rest. edge := NewCanvas(4, 4) edge.Blit(3, 3, src) uassert.Equal(t, "\n\n\n a", edge.Text(), "only the top-left cell lands") // A nil source is a no-op rather than a panic. edge.Blit(0, 0, nil) } func TestBoxAndFrame(t *testing.T) { c := NewCanvas(5, 3) c.Box(0, 0, 5, 3, ASCII) uassert.Equal(t, "+---+\n| |\n+---+", c.Text(), "ascii border") // Degenerate boxes draw nothing: there is no border with no inside. small := NewCanvas(5, 3) small.Box(0, 0, 1, 3, Light) small.Box(0, 0, 5, 1, Light) uassert.Equal(t, "\n\n", small.Text(), "width or height under 2 draws nothing") inner := NewCanvas(3, 1) inner.Write(0, 0, "hey") framed := inner.Frame("", ASCII) uassert.Equal(t, 5, framed.W, "frame adds two columns") uassert.Equal(t, 3, framed.H, "frame adds two rows") uassert.Equal(t, "+---+\n|hey|\n+---+", framed.Text(), "content centred inside") } func TestFrameTitleIsTruncatedNotOverrun(t *testing.T) { inner := NewCanvas(4, 1) titled := inner.Frame("settlers", ASCII) // 6 columns wide: the corners take 2 and the title starts at x=2, so it // gets 2 cells and must not eat the top-right corner. uassert.True(t, strings.HasPrefix(titled.Text(), "+-se-+\n"), "truncated, corner survives") // A canvas too narrow for any title still frames cleanly. tiny := NewCanvas(1, 1) uassert.Equal(t, "+-+\n| |\n+-+", tiny.Frame("nope", ASCII).Text(), "no room, no title") } // stripSGR removes every SGR sequence, so a test can assert on the runes and on // the escapes separately instead of pinning one long opaque string. func stripSGR(s string) string { var b strings.Builder in := false for _, r := range s { switch { case r == Escape: in = true case in && r == 'm': in = false case in: default: b.WriteRune(r) } } return b.String() }
- #10color.gno
- #11package art import "strings" // Default is the colour that means "let the consumer decide": the terminal's // own foreground or background in ANSI, and transparent in SVG. const Default = -1 // 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 RGB(r, g, b int) int { return clamp8(r)<<16 | clamp8(g)<<8 | clamp8(b) } // 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 Split(c int) (r, g, b int) { if c < 0 { return 0, 0, 0 } return (c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff } // 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 ParseHex(s string) (int, bool) { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "#") switch len(s) { case 3: r, ok1 := hexDigit(s[0]) g, ok2 := hexDigit(s[1]) b, ok3 := hexDigit(s[2]) if !ok1 || !ok2 || !ok3 { return Default, false } // #abc is #aabbcc, not #0a0b0c. return RGB(r*17, g*17, b*17), true case 6: r, ok1 := hexByte(s[0], s[1]) g, ok2 := hexByte(s[2], s[3]) b, ok3 := hexByte(s[4], s[5]) if !ok1 || !ok2 || !ok3 { return Default, false } return RGB(r, g, b), true } return Default, false } // Hex is the inverse of ParseHex. Default renders as the empty string, because // there is no hex spelling of "the consumer's default". func Hex(c int) string { if c < 0 { return "" } r, g, b := Split(c) var sb strings.Builder sb.WriteByte('#') writeHexByte(&sb, r) writeHexByte(&sb, g) writeHexByte(&sb, b) return sb.String() } // 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 Luminance(c int) int { if c < 0 { return 0 } r, g, b := Split(c) return (2126*r + 7152*g + 722*b) / 10000 } func clamp8(v int) int { if v < 0 { return 0 } if v > 255 { return 255 } return v } func hexDigit(c byte) (int, bool) { switch { case c >= '0' && c <= '9': return int(c - '0'), true case c >= 'a' && c <= 'f': return int(c-'a') + 10, true case c >= 'A' && c <= 'F': return int(c-'A') + 10, true } return 0, false } func hexByte(hi, lo byte) (int, bool) { h, ok1 := hexDigit(hi) l, ok2 := hexDigit(lo) if !ok1 || !ok2 { return 0, false } return h*16 + l, true } const hexDigits = "0123456789abcdef" func writeHexByte(sb *strings.Builder, v int) { sb.WriteByte(hexDigits[(v>>4)&0xf]) sb.WriteByte(hexDigits[v&0xf]) }
- #12color_test.gno
- #13package art import ( "testing" "gno.land/p/nt/uassert/v0" ) func TestParseHex(t *testing.T) { cases := []struct { name string in string want int ok bool }{ {"six with hash", "#e8c39e", 0xe8c39e, true}, {"six without hash", "e8c39e", 0xe8c39e, true}, {"uppercase", "#E8C39E", 0xe8c39e, true}, {"short expands by duplication", "#abc", 0xaabbcc, true}, {"short is not zero padded", "#abc", 0x0a0b0c, false}, {"surrounding space", " #000000 ", 0x000000, true}, {"white", "#ffffff", 0xffffff, true}, {"empty is not a colour", "", Default, false}, {"four digits", "#abcd", Default, false}, {"non hex digit", "#gggggg", Default, false}, } for _, tc := range cases { got, ok := ParseHex(tc.in) if !tc.ok { // Either it failed to parse, or it parsed to something other than // the value this case says it must not produce. uassert.False(t, ok && got == tc.want, tc.name) continue } uassert.True(t, ok, tc.name+": parsed") uassert.Equal(t, tc.want, got, tc.name) } } func TestHexRoundTrip(t *testing.T) { for _, s := range []string{"#000000", "#ffffff", "#e8c39e", "#0a0b0c"} { c, ok := ParseHex(s) uassert.True(t, ok, s) uassert.Equal(t, s, Hex(c), s) } uassert.Equal(t, "", Hex(Default), "Default has no hex spelling") } func TestRGBClamps(t *testing.T) { uassert.Equal(t, 0xffffff, RGB(300, 300, 300), "over range clamps to ff") uassert.Equal(t, 0x000000, RGB(-5, -5, -5), "under range clamps to 00") // The point of clamping rather than masking: 256 must not alias to 0 and // carry a bit into the next channel. uassert.Equal(t, 0x00ff00, RGB(0, 256, 0), "256 does not carry into red") } func TestLuminance(t *testing.T) { cases := []struct { name string in int want int }{ {"black", 0x000000, 0}, {"white", 0xffffff, 255}, {"pure green is the brightest primary", 0x00ff00, 182}, {"pure blue is the darkest primary", 0x0000ff, 18}, {"pure red", 0xff0000, 54}, {"Default reads as black", Default, 0}, } for _, tc := range cases { uassert.Equal(t, tc.want, Luminance(tc.in), tc.name) } }
- #14doc.gno
- #15// 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. package art
- #16example_test.gno
- #17package art // heart is an 8x6 indexed sprite: 0 background, 1 body, 2 highlight. var ( heartPal = Palette{"", "#d64550", "#f7a8b0"} heartIdx = []uint8{ 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, } ) func heart() *Pix { return &Pix{W: 8, H: 6, Idx: heartIdx, Pal: heartPal} } // One glyph per palette index, each pixel two cells wide so the sprite keeps // its proportions in a terminal. This is the default for indexed art. func ExamplePix_Canvas_glyphs() { println(heart().Canvas(Glyphs).Text()) // Output: // #### #### // ######@@@@###### // ################ // ############ // ######## // #### } // The same sprite at four times the density, in Braille. Monochrome by // construction: a Braille cell has eight dots and no colours, so a pixel is // only on or off. func ExamplePix_Canvas_braille() { println(heart().Canvas(Braille).Text()) // Output: // ⢾⣷⣾⡷ // ⠀⠙⠋⠀ } // Quadrants pack 2x2 into one cell. Asked for no colour, the mode has nothing // left to say with two colours and degrades to a density map, which is still // the densest thing a plain-text consumer can be handed. func ExamplePix_Canvas_quadrant() { println(heart().CanvasOpts(Opts{Mode: Quadrant}).Text()) // Output: // ▟▙▟▙ // ▜██▛ // ▜▛ } // Drawing on a Canvas directly, with a border around it. func ExampleCanvas_Frame() { c := NewCanvas(11, 3) c.Write(0, 0, "gno.land") c.HLine(0, 1, 11, '─') c.Write(0, 2, "ascii art") println(c.Frame("art", Round).Text()) // Output: // ╭─art───────╮ // │gno.land │ // │───────────│ // │ascii art │ // ╰───────────╯ }
- #18gnomod.toml
- #19module = "gno.land/p/moul/art/v0" gno = "0.9"
- #20pix.gno
- #21package art import ( "strings" "gno.land/p/moul/svg/v0" ) // 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. type Palette []string // 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. func (p Palette) Color(i uint8) int { if int(i) >= len(p) { return Default } c, ok := ParseHex(p[i]) if !ok { return Default } return c } // 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. type Pix struct { W, H int Idx []uint8 Pal Palette } // NewPix returns a w by h bitmap with every pixel at index 0. func NewPix(w, h int, pal Palette) *Pix { if w < 0 { w = 0 } if h < 0 { h = 0 } return &Pix{W: w, H: h, Idx: make([]uint8, w*h), Pal: pal} } // 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 (p *Pix) At(x, y int) uint8 { if x < 0 || y < 0 || x >= p.W || y >= p.H { return 0 } return p.Idx[y*p.W+x] } // Set writes a palette index, clipping silently off the bitmap. func (p *Pix) Set(x, y int, i uint8) { if x < 0 || y < 0 || x >= p.W || y >= p.H { return } p.Idx[y*p.W+x] = i } // Color returns the packed colour at (x, y). func (p *Pix) Color(x, y int) int { return p.Pal.Color(p.At(x, y)) } // 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 Mode int const ( // Glyphs gives every palette index its own rune. The right default for // sprites and anything else with a small indexed palette. Glyphs Mode = iota // Ramp picks a rune by luminance. Right for a photograph, wrong for a // sprite, and the package doc has the measurement. Ramp // HalfBlock packs two vertical pixels into one cell with '▀', foreground // for the top pixel and background for the bottom. Full colour, square // aspect, half the rows. The best-looking mode, and terminal-only. HalfBlock // Quadrant packs a 2x2 block into one cell. Twice HalfBlock's density, // but a cell carries only two colours, so a four-colour block loses two. Quadrant // Braille packs a 2x4 block into one Braille cell. The densest mode and // the only monochrome one: a pixel is on if its index is not 0. Braille ) // 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 DefaultGlyphs = []rune{' ', '#', '@', '%', '*', '+', '=', '~', '-', ':', '.', 'o', 'O', 'x', 'X', 'w', 'W', 'm', 'M', '8'} // DefaultRamp runs darkest to lightest, for a terminal with a dark background. // Invert it for a light one. var DefaultRamp = []rune{' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'} // Quadrants is indexed by a 4-bit mask: bit 0 top-left, 1 top-right, 2 // bottom-left, 3 bottom-right. var Quadrants = []rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'} // Opts is the full form of [Pix.Canvas], for callers who want to override a // mode's defaults. type Opts struct { Mode Mode // Glyphs overrides [DefaultGlyphs] for [Glyphs] and [DefaultRamp] for // [Ramp]. Ignored by the block modes. Glyphs []rune // Color carries palette colours into the canvas. The block modes need it // and set it by default; the glyph modes default to off, because their // whole job is to be readable without colour. Color bool // Wide emits each pixel as two cells side by side. A terminal cell is // about twice as tall as it is wide, so a sprite rendered one cell per // pixel comes out squashed to half height. On by default for the glyph // modes; meaningless for the block modes, which correct aspect by packing. Wide bool } // Canvas converts the bitmap using a mode's defaults: [Glyphs] and [Ramp] come // out wide and monochrome, the block modes come out coloured. func (p *Pix) Canvas(m Mode) *Canvas { o := Opts{Mode: m} switch m { case Glyphs, Ramp: o.Wide = true default: o.Color = true } return p.CanvasOpts(o) } // CanvasOpts converts the bitmap with the options spelled out. func (p *Pix) CanvasOpts(o Opts) *Canvas { switch o.Mode { case HalfBlock: return p.halfBlock(o) case Quadrant: return p.quadrant(o) case Braille: return p.braille(o) case Ramp: return p.glyphGrid(o, pickRamp(o.Glyphs)) default: return p.glyphGrid(o, pickGlyphs(o.Glyphs)) } } func pickGlyphs(g []rune) []rune { if len(g) == 0 { return DefaultGlyphs } return g } func pickRamp(g []rune) []rune { if len(g) == 0 { return DefaultRamp } return g } // glyphGrid covers both Glyphs and Ramp: they differ only in what they key the // rune lookup on, which is exactly the finding the package doc records. func (p *Pix) glyphGrid(o Opts, set []rune) *Canvas { step := 1 if o.Wide { step = 2 } out := NewCanvas(p.W*step, p.H) for y := 0; y < p.H; y++ { for x := 0; x < p.W; x++ { idx := p.At(x, y) col := p.Pal.Color(idx) var r rune if o.Mode == Ramp { r = set[Luminance(col)*len(set)/256] } else { r = set[int(idx)%len(set)] } cell := Cell{R: r, FG: Default, BG: Default} if o.Color { cell.FG = col } for k := 0; k < step; k++ { out.Set(x*step+k, y, cell) } } } return out } func (p *Pix) halfBlock(o Opts) *Canvas { out := NewCanvas(p.W, (p.H+1)/2) for y := 0; y < out.H; y++ { for x := 0; x < p.W; x++ { top := p.Color(x, y*2) bot := Default if y*2+1 < p.H { bot = p.Color(x, y*2+1) } if !o.Color { // Without colour the upper half block says nothing, so fall // back to "is there ink here": full, half, or empty. out.Set(x, y, Cell{R: monoHalf(p.At(x, y*2), p.At(x, y*2+1)), FG: Default, BG: Default}) continue } out.Set(x, y, Cell{R: '▀', FG: top, BG: bot}) } } return out } func monoHalf(top, bot uint8) rune { switch { case top != 0 && bot != 0: return '█' case top != 0: return '▀' case bot != 0: return '▄' } return ' ' } func (p *Pix) quadrant(o Opts) *Canvas { out := NewCanvas((p.W+1)/2, (p.H+1)/2) for y := 0; y < out.H; y++ { for x := 0; x < out.W; x++ { var idx [4]uint8 idx[0] = p.At(x*2, y*2) idx[1] = p.At(x*2+1, y*2) idx[2] = p.At(x*2, y*2+1) idx[3] = p.At(x*2+1, y*2+1) if !o.Color { mask := 0 for k := 0; k < 4; k++ { if idx[k] != 0 { mask |= 1 << uint(k) } } out.Set(x, y, Cell{R: Quadrants[mask], FG: Default, BG: Default}) continue } bgIdx, fgIdx, split := twoWaySplit(idx) mask := 0 if split { for k := 0; k < 4; k++ { if idx[k] == fgIdx { mask |= 1 << uint(k) } } } fg := Default if split { fg = p.Pal.Color(fgIdx) } out.Set(x, y, Cell{R: Quadrants[mask], FG: fg, BG: p.Pal.Color(bgIdx)}) } } return out } // twoWaySplit picks the two palette indices a 2x2 block is drawn with: the most // common becomes the background, the most common of the rest the foreground. // It reports false when every pixel agrees, in which case there is no // foreground and the cell is a solid background. // // A tie goes to the LOWER palette index, which is what makes index 0 behave as // the background the package documents it to be. Tie-breaking on pixel order // instead put the ink in the background half the time: a two-colour block split // down the middle came out as the mirror image of itself, because whichever // side happened to be scanned first won. func twoWaySplit(idx [4]uint8) (bg, fg uint8, split bool) { bg = mostCommon(idx, false, 0) fg = mostCommon(idx, true, bg) if fg == bg { return bg, bg, false } return bg, fg, true } func mostCommon(idx [4]uint8, skip bool, skipped uint8) uint8 { best, bestN := uint8(0), 0 for k := 0; k < 4; k++ { if skip && idx[k] == skipped { continue } n := 0 for j := 0; j < 4; j++ { if idx[j] == idx[k] { n++ } } if n > bestN || (n == bestN && bestN > 0 && idx[k] < best) { best, bestN = idx[k], n } } if bestN == 0 { return skipped } return best } // brailleBits maps (col, row) inside a 2x4 block to its bit in U+2800. The // layout is not sequential: the fourth row was added to the standard late and // took the two high bits. var brailleBits = [2][4]uint{ {0, 1, 2, 6}, {3, 4, 5, 7}, } func (p *Pix) braille(o Opts) *Canvas { out := NewCanvas((p.W+1)/2, (p.H+3)/4) for y := 0; y < out.H; y++ { for x := 0; x < out.W; x++ { bits := 0 fg := Default for col := 0; col < 2; col++ { for row := 0; row < 4; row++ { if p.At(x*2+col, y*4+row) == 0 { continue } bits |= 1 << brailleBits[col][row] if o.Color && fg == Default { fg = p.Color(x*2+col, y*4+row) } } } out.Set(x, y, Cell{R: rune(0x2800 + bits), FG: fg, BG: Default}) } } return out } // 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 (p *Pix) SVG(scale int) *svg.Canvas { if scale < 1 { scale = 1 } out := svg.NewCanvas(p.W*scale, p.H*scale) out.WithViewBox(0, 0, p.W, p.H) out.AddStyle("path", "shape-rendering:crispEdges") for i := 0; i < len(p.Pal); i++ { hex := p.Pal.hex(uint8(i)) if hex == "" { continue } var d strings.Builder for y := 0; y < p.H; y++ { x := 0 for x < p.W { if p.At(x, y) != uint8(i) { x++ continue } run := 1 for x+run < p.W && p.At(x+run, y) == uint8(i) { run++ } d.WriteByte('M') d.WriteString(itoa(x)) d.WriteByte(' ') d.WriteString(itoa(y)) d.WriteByte('h') d.WriteString(itoa(run)) d.WriteString("v1h-") d.WriteString(itoa(run)) d.WriteByte('z') x += run } } if d.Len() > 0 { out.Append(svg.NewPath(d.String(), hex)) } } return out } // hex returns a palette entry normalised to "#rrggbb", or "" for a transparent // or unparseable one. Normalising rather than passing the raw string through is // what keeps an entry a caller typed out of the SVG document unescaped. func (p Palette) hex(i uint8) string { if int(i) >= len(p) { return "" } c, ok := ParseHex(p[i]) if !ok { return "" } return Hex(c) }
- #22pix_test.gno
- #23package art import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // sprite builds a small indexed bitmap from a rune-per-pixel literal, so a test // can show the input instead of listing indices. func sprite(pal Palette, rows ...string) *Pix { w := 0 for _, r := range rows { if n := len([]rune(r)); n > w { w = n } } p := NewPix(w, len(rows), pal) for y, row := range rows { for x, r := range []rune(row) { if r == '.' { continue } p.Set(x, y, uint8(r-'0')) } } return p } // TestRampCollapsesWhatGlyphsKeepsApart is the finding the whole package turns // on: a luminance ramp is the wrong conversion for indexed art. // // Sand (#e8dcc8) and bone (#d9d9d9) are two distinct palette entries any sprite // might carry. Their luminances are 221 and 217, four steps apart out of 255, // so a ten-step ramp drops both into bucket 8 and the shape between them // disappears. Keying on the palette index instead cannot do that, because the // index IS the artist's own segmentation. func TestRampCollapsesWhatGlyphsKeepsApart(t *testing.T) { pal := Palette{"#000000", "#e8dcc8", "#d9d9d9"} uassert.Equal(t, 221, Luminance(0xe8dcc8), "sand") uassert.Equal(t, 217, Luminance(0xd9d9d9), "bone") p := sprite(pal, "0112") ramp := p.CanvasOpts(Opts{Mode: Ramp}).Text() glyph := p.CanvasOpts(Opts{Mode: Glyphs}).Text() // Both non-background entries land on the same ramp rune, so the two // regions merge into one blob. uassert.Equal(t, " %%%", ramp, "ramp merges sand and bone") uassert.Equal(t, 2, distinctRunes(ramp), "ramp shows two distinct runes") // Glyphs keeps three, one per palette index. uassert.Equal(t, " ##@", glyph, "glyphs keep every index apart") uassert.Equal(t, 3, distinctRunes(glyph), "glyphs show three distinct runes") } func TestGlyphDefaultsAreWideAndMonochrome(t *testing.T) { p := sprite(Palette{"", "#ff0000"}, "01") // A terminal cell is about twice as tall as it is wide, so one cell per // pixel would squash a sprite to half height. wide := p.Canvas(Glyphs) uassert.Equal(t, 4, wide.W, "default doubles the width") uassert.Equal(t, " ##", wide.Text(), "each pixel is two cells") uassert.Equal(t, Default, wide.At(2, 0).FG, "no colour by default") narrow := p.CanvasOpts(Opts{Mode: Glyphs, Color: true}) uassert.Equal(t, 2, narrow.W, "Wide off means one cell per pixel") uassert.Equal(t, 0xff0000, narrow.At(1, 0).FG, "Color on carries the palette through") } func TestCustomGlyphsAndWrapping(t *testing.T) { p := sprite(Palette{"", "", ""}, "012") got := p.CanvasOpts(Opts{Mode: Glyphs, Glyphs: []rune{'a', 'b'}}).Text() // Two glyphs for three indices: index 2 wraps back onto 'a'. A real // collision, which is why the doc tells you to pass your own set. uassert.Equal(t, "aba", got, "a short glyph set wraps") } func TestHalfBlock(t *testing.T) { pal := Palette{"", "#ff0000", "#0000ff"} p := sprite(pal, "11", "22", "11") c := p.Canvas(HalfBlock) uassert.Equal(t, 2, c.W, "width is unchanged") uassert.Equal(t, 2, c.H, "three rows pack into two cells") top := c.At(0, 0) uassert.Equal(t, '▀', top.R, "upper half block") uassert.Equal(t, 0xff0000, top.FG, "foreground is the top pixel") uassert.Equal(t, 0x0000ff, top.BG, "background is the bottom pixel") // The odd last row has no partner, so its background falls to Default // rather than reading a pixel that is not there. uassert.Equal(t, Default, c.At(0, 1).BG, "odd height leaves the last background unset") } func TestHalfBlockMonochromeFallsBackToInk(t *testing.T) { p := sprite(Palette{"", "#ffffff"}, "10", "01") // Without colour, '▀' everywhere would say nothing at all, so the mode // reports which halves carry ink instead. uassert.Equal(t, "▀▄", p.CanvasOpts(Opts{Mode: HalfBlock}).Text(), "ink map") } func TestQuadrant(t *testing.T) { pal := Palette{"#000000", "#ff0000"} // A solid 2x2 block of one index has no foreground at all. solid := sprite(pal, "11", "11").Canvas(Quadrant) uassert.Equal(t, ' ', solid.At(0, 0).R, "one colour means a blank cell on a solid background") uassert.Equal(t, 0xff0000, solid.At(0, 0).BG, "and that colour is the background") uassert.Equal(t, Default, solid.At(0, 0).FG, "with no foreground") // Three background pixels and one foreground: the minority becomes the // glyph. Bit 0 is top-left. corner := sprite(pal, "10", "00").Canvas(Quadrant) uassert.Equal(t, '▘', corner.At(0, 0).R, "top-left quadrant") uassert.Equal(t, 0xff0000, corner.At(0, 0).FG, "the minority index is the foreground") left := sprite(pal, "10", "10").Canvas(Quadrant) uassert.Equal(t, '▌', left.At(0, 0).R, "left half") } func TestQuadrantPacksFourToOne(t *testing.T) { p := sprite(Palette{"", "#fff"}, "1111", "1111", "1111", "1111") c := p.Canvas(Quadrant) uassert.Equal(t, 2, c.W, "half the columns") uassert.Equal(t, 2, c.H, "half the rows") } func TestBraille(t *testing.T) { p := sprite(Palette{"", "#ffffff"}, "10", "00", "00", "00") c := p.Canvas(Braille) uassert.Equal(t, 1, c.W, "2 columns pack into 1") uassert.Equal(t, 1, c.H, "4 rows pack into 1") uassert.Equal(t, rune(0x2801), c.At(0, 0).R, "top-left dot is bit 0") // The fourth row took the high bits when it was added to the standard, // which is the one thing about Braille that is not sequential. low := sprite(Palette{"", "#ffffff"}, "00", "00", "00", "10").Canvas(Braille) uassert.Equal(t, rune(0x2840), low.At(0, 0).R, "bottom-left dot is bit 6") full := sprite(Palette{"", "#ffffff"}, "11", "11", "11", "11").Canvas(Braille) uassert.Equal(t, rune(0x28ff), full.At(0, 0).R, "all eight dots") empty := sprite(Palette{"", "#ffffff"}, "00").Canvas(Braille) uassert.Equal(t, rune(0x2800), empty.At(0, 0).R, "blank braille, not a space") } func TestPixSVGRunLength(t *testing.T) { pal := Palette{"", "#ff0000"} p := sprite(pal, "0111", "0000") doc := p.SVG(4).String() // Index 0 is transparent, so it emits nothing; the three red pixels become // ONE run in ONE path, not three rectangles. uassert.Equal(t, 1, strings.Count(doc, "<path"), "one path per colour") uassert.True(t, strings.Contains(doc, `d="M1 0h3v1h-3z"`), "one run, in pixel coordinates") uassert.True(t, strings.Contains(doc, `fill="#ff0000"`), "run colour") // Scale goes on the canvas, not into the path, so scaling costs no bytes. uassert.True(t, strings.Contains(doc, `width="16" height="8"`), "canvas is scaled") uassert.True(t, strings.Contains(doc, `viewBox="0 0 4 2"`), "viewBox stays in pixel units") } func TestPixSVGNormalisesColour(t *testing.T) { // A short-form entry is expanded rather than passed through, which is what // keeps an unparseable string a caller typed out of the document. p := sprite(Palette{"#abc"}, "0") uassert.True(t, strings.Contains(p.SVG(1).String(), `fill="#aabbcc"`), "short form expanded") junk := sprite(Palette{`" onload="x`}, "0") uassert.Equal(t, 0, strings.Count(junk.SVG(1).String(), "<path"), "an unparseable entry emits nothing") } func TestPaletteColorOutOfRange(t *testing.T) { pal := Palette{"#000000"} uassert.Equal(t, Default, pal.Color(7), "past the end is Default, not a panic") uassert.Equal(t, Default, Palette{""}.Color(0), "an empty entry is transparent") } func distinctRunes(s string) int { var seen []rune for _, r := range s { found := false for _, o := range seen { if o == r { found = true break } } if !found { seen = append(seen, r) } } return len(seen) }
- #24settler_test.gno
Result log
msg:0,success:true,log:,events:[]