stain

package module
v0.0.0-...-74ef9e0 Latest Latest
Warning

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

Go to latest
Published: Apr 9, 2026 License: MIT Imports: 10 Imported by: 1

README

stain

CI Go Reference

A high-performance terminal styling library that renders lipgloss styles 13–51× faster with zero allocations in steady state.

Stain is a drop-in rendering layer for charm.land/lipgloss/v2. Build your styles with lipgloss as you always have. Compile each style once. Render thousands of times per frame with no garbage collector pressure.

import (
    "fmt"

    "charm.land/lipgloss/v2"
    "github.com/pgavlin/stain"
)

// Build with lipgloss.
spec := lipgloss.NewStyle().
    Bold(true).
    Foreground(lipgloss.Color("#F25D94")).
    Padding(1, 2).
    Border(lipgloss.NormalBorder())

// Compile once at startup.
style := stain.Compile(spec, stain.TrueColor)

// Render thousands of times — zero allocations after warmup.
var b stain.Block
for _, msg := range messages {
    style.RenderTo(&b, msg)
    fmt.Println(b.String())
}

Why stain?

Lipgloss is great for getting started, but its rendering pipeline allocates heavily and re-derives layout decisions on every call. For TUIs that render hundreds or thousands of styled cells per frame (grids, lists, dashboards), that overhead becomes the bottleneck.

Stain takes a different approach:

  • Compile once -- all ANSI sequences, padding strings, and border edges are pre-computed. The hot path makes no color decisions and builds no escape sequences at runtime.
  • Measured-content Blocks -- renders return a Block carrying width, height, and per-line offsets alongside the bytes. Layout primitives compose Blocks without re-measuring.
  • Caller-owned buffers -- RenderTo(&block, content) writes into a Block you supply. Reuse it across renders to amortize allocation to zero.

Performance

Side-by-side benchmarks against stock lipgloss v2.0.2 on an Apple M4 Max (Go 1.25.1):

Benchmark lipgloss stain Speedup
Noop 785 ns / 0 alloc 35 ns / 0 alloc 23×
Bold 1.22 µs / 3 alloc 39 ns / 0 alloc 31×
FgBg 1.71 µs / 13 alloc 39 ns / 0 alloc 45×
Padding 1.50 µs / 12 alloc 70 ns / 0 alloc 21×
Border 3.81 µs / 44 alloc 94 ns / 0 alloc 40×
BorderColor 4.58 µs / 60 alloc 93 ns / 0 alloc 49×
FullBox 7.46 µs / 123 alloc 155 ns / 0 alloc 48×
WidthAlign 1.72 µs / 29 alloc 65 ns / 0 alloc 26×
Multiline 1.89 µs / 18 alloc 141 ns / 0 alloc 13×
FullFrame 9.92 µs / 178 alloc 195 ns / 0 alloc 51×

Reproduce with:

go test -run='^$' -bench=BenchmarkCompare -benchmem -count=6 .
benchstat -col /impl compare.txt

The expanded benchmark suite (bench_*_test.go) covers content shapes, layout primitives, full grid frames, and API overhead.

Lifecycle

lipgloss.Style ── stain.Compile(profile) ──> *stain.Style ── Render ──> Block
   (config)                                    (frozen)              (measured)
  • Configuration / inheritance / fluent builder: lipgloss owns it. Use any lipgloss feature stain supports (see gaps below).
  • Compile: one-time per (style, profile) pair. Reads via lipgloss getters and pre-computes the full ANSI cache.
  • Render: stitches precomputed pieces into a Block. No color decisions, no SGR construction, no per-line measurement.
  • Compose: layout primitives operate on Blocks. Dimensions are known, so no re-measurement.
  • Output: Block.WriteTo(io.Writer) for streaming, Block.String() for log/print, or feed it to another Style.RenderBlock for nesting.

Installation

go get github.com/pgavlin/stain

Requires Go 1.25+. Depends on charm.land/lipgloss/v2 and github.com/charmbracelet/x/ansi.

Quick start

Basic render
package main

import (
    "fmt"
    "image/color"

    "charm.land/lipgloss/v2"
    "github.com/pgavlin/stain"
)

func main() {
    spec := lipgloss.NewStyle().
        Bold(true).
        Foreground(color.RGBA{R: 255, A: 255}).
        Border(lipgloss.RoundedBorder()).
        Padding(1, 2)

    style := stain.Compile(spec, stain.TrueColor)
    fmt.Println(style.Render("Hello, stain!").String())
}
Hot loop (zero allocation)
style := stain.Compile(spec, stain.TrueColor)
var b stain.Block

for i := 0; i < 1000; i++ {
    style.RenderTo(&b, fmt.Sprintf("row %d", i))
    // b.String() / b.Bytes() / b.WriteTo(w)
}
Layout
left := leftStyle.Render("sidebar")
right := rightStyle.Render("main panel\ncontent\nhere")

// Side-by-side layout.
both := stain.JoinHorizontal(lipgloss.Top, left, right)

// Stack vertically.
page := stain.JoinVertical(lipgloss.Left, headerBlock, both, footerBlock)

// Center inside a fixed region.
centered := stain.Place(80, 24, lipgloss.Center, lipgloss.Center, page)
Nested rendering

RenderBlock wraps a pre-measured Block in another style's frame:

inner := innerStyle.Render("important message")
outer := outerStyle.RenderBlock(inner) // outer frame around inner block

API

// Profile selects color encoding.
type Profile int
const (
    NoColor Profile = iota
    ANSI16
    ANSI256
    TrueColor
)

// Compile reads a lipgloss.Style and produces a frozen, profile-pinned Style.
func Compile(s lipgloss.Style, profile Profile) *Style

// Render returns a new Block. RenderTo writes into a caller-owned Block.
func (s *Style) Render(content string) Block
func (s *Style) RenderTo(out *Block, content string)
func (s *Style) String(content string) string

// RenderBlock wraps a pre-measured Block in s's frame.
func (s *Style) RenderBlock(b Block) Block
func (s *Style) RenderBlockTo(out *Block, b Block)

// Block carries rendered bytes plus dimensions and line offsets.
type Block struct { /* ... */ }
func (b Block) Width() int
func (b Block) Height() int
func (b Block) Bytes() []byte
func (b Block) String() string
func (b Block) WriteTo(w io.Writer) (int64, error)
func (b *Block) Reset()

// Layout primitives.
func JoinHorizontal(pos lipgloss.Position, blocks ...Block) Block
func JoinVertical(pos lipgloss.Position, blocks ...Block) Block
func Place(w, h int, hPos, vPos lipgloss.Position, b Block) Block
// ... plus *To variants and JoinString* string overloads.

Examples

The examples/ directory contains runnable programs:

  • basic — minimal Compile + Render
  • grid — table rendering with JoinHorizontal / JoinVertical
  • dashboard — multi-component TUI layout
  • layout — port of the lipgloss "kitchen sink" layout demo (tabs, dialogs, lists, color grids, status bar, compositing)
  • color — colored frame with bordered dialog
  • brightnessLighten/Darken color variations
  • blending/linear-1d — 1D color gradients
  • blending/linear-2d — 2D color gradients

Run with:

go run ./examples/basic
go run ./examples/grid
go run ./examples/layout

Gaps from lipgloss

Stain intentionally does not support a few lipgloss features:

  • Style.Transform — call your transform on the input string yourself before passing it to Render.
  • Style.SetStringRender always takes content as an argument; there's no default content carried on the Style.
  • StyleRanges — substring styling is out of scope for v1.
  • Tables, lists, trees — those live in lipgloss subpackages and continue to consume lipgloss.Style directly. They're not rendered through stain (yet).

Profile pinning

A *stain.Style is pinned to the Profile you pass to Compile. To target multiple profiles (e.g., the user toggles a setting), compile each style once per profile and look up the right one at render time. There's no built-in cache yet; callers manage their own map[string]*stain.Style for now.

For most apps: detect the terminal profile once at startup, compile every style with that profile, and you're done.

Equivalence with lipgloss

Stain's output is visually equivalent to lipgloss's. The equivalence test suite (equivalence_test.go) cross-validates 60+ style configurations against lipgloss by parsing both outputs into (rune, fg, bg, attrs, hyperlink) cell grids and comparing cell-by-cell. Byte sequences may differ in SGR segmentation, but every cell gets the same attributes.

Acknowledgements

Stain builds on Charmbracelet's lipgloss for the configuration API, color types, and underlying ANSI utilities (via github.com/charmbracelet/x/ansi).

License

MIT — see the LICENSE file.

Documentation

Overview

Package stain is a high-performance terminal styling library that consumes lipgloss.Style configurations and renders them to bytes.

Overview

Stain compiles a lipgloss.Style into a *stain.Style for a given color profile, precomputing every ANSI sequence and static layout string. Subsequent calls to Render write the precomputed pieces into a Block, the measured-content type that carries width, height, and line offsets alongside the rendered bytes. Layout primitives (JoinHorizontal, JoinVertical, Place) operate on Blocks without re-measuring.

The hot path of stain.Style.Render makes no color decisions, builds no SGR sequences at runtime, and emits all bytes via direct []byte append. In steady-state hot loops with reused Blocks, Render is allocation-free.

Lifecycle

lipgloss.Style ── Compile(profile) ──> *stain.Style ── Render ──> Block

Configuration, inheritance, and the fluent builder all live in lipgloss. Stain is purely the rendering layer.

Gaps from lipgloss

The following lipgloss features are intentionally NOT supported:

  • lipgloss.Style.Transform — call your transform on the input string before passing it to Render.
  • lipgloss.Style.SetString — Render always takes content as an argument; there's no default content carried on the Style.
  • StyleRanges — out of scope for v1.

Profile pinning

A *stain.Style is pinned to the Profile passed to Compile. To target multiple profiles (e.g., user toggles a setting), Compile each style once per profile and look up the right one at render time. A built-in cache is deferred to a future version; callers manage their own map[string]*stain.Style for now.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func JoinHorizontalTo

func JoinHorizontalTo(out *Block, pos lipgloss.Position, blocks ...Block)

JoinHorizontalTo is the output-parameter form of JoinHorizontal. It resets out before writing, reusing its buffer capacity.

func JoinStringHorizontalTo

func JoinStringHorizontalTo(out *Block, pos lipgloss.Position, lines ...string)

JoinStringHorizontalTo is the output-parameter form of JoinStringHorizontal.

func JoinStringVerticalTo

func JoinStringVerticalTo(out *Block, pos lipgloss.Position, lines ...string)

JoinStringVerticalTo is the output-parameter form of JoinStringVertical.

func JoinVerticalTo

func JoinVerticalTo(out *Block, pos lipgloss.Position, blocks ...Block)

JoinVerticalTo is the output-parameter form of JoinVertical. It resets out before writing, reusing its buffer capacity.

func PlaceTo

func PlaceTo(out *Block, w, h int, hPos, vPos lipgloss.Position, b Block)

PlaceTo is the output-parameter form of Place. It resets out before writing, reusing its buffer capacity.

Types

type Block

type Block struct {
	// contains filtered or unexported fields
}

Block is rendered, measured terminal content. The zero value is a valid empty block (width 0, height 0, no bytes).

Block carries the rendered bytes alongside its measured dimensions and per-line byte offsets, so layout primitives can compose multiple Blocks without re-measuring.

A Block's underlying buffer is owned by the Block; callers must not retain slices returned by Bytes across mutations.

func JoinHorizontal

func JoinHorizontal(pos lipgloss.Position, blocks ...Block) Block

JoinHorizontal places blocks side by side, aligning rows according to pos (Top, Center, or Bottom). Shorter blocks are padded with spaces.

func JoinStringHorizontal

func JoinStringHorizontal(pos lipgloss.Position, lines ...string) Block

JoinStringHorizontal is a convenience wrapper around JoinHorizontal that accepts raw strings instead of pre-measured Blocks.

func JoinStringVertical

func JoinStringVertical(pos lipgloss.Position, lines ...string) Block

JoinStringVertical is a convenience wrapper around JoinVertical that accepts raw strings instead of pre-measured Blocks.

func JoinVertical

func JoinVertical(pos lipgloss.Position, blocks ...Block) Block

JoinVertical stacks blocks top-to-bottom, aligning columns according to pos (Left, Center, or Right). Narrower blocks are padded with spaces.

func Place

func Place(w, h int, hPos, vPos lipgloss.Position, b Block) Block

Place centers b inside a w-by-h cell, using hPos and vPos to control horizontal and vertical alignment respectively.

func (Block) Bytes

func (b Block) Bytes() []byte

Bytes returns the rendered bytes. The returned slice aliases the Block's internal buffer; callers must not modify it and must not retain it past the next Reset or render into the same Block.

func (Block) Height

func (b Block) Height() int

Height returns the number of lines in b.

func (*Block) Reset

func (b *Block) Reset()

Reset clears b but retains its underlying buffer capacity for reuse. Use Reset between renders in a hot loop to avoid allocation.

func (Block) String

func (b Block) String() string

String returns the rendered bytes as a string. This copies the bytes.

func (Block) Width

func (b Block) Width() int

Width returns the maximum display width across all lines of b.

func (Block) WriteTo

func (b Block) WriteTo(w io.Writer) (int64, error)

WriteTo writes the rendered bytes to w. Implements io.WriterTo.

type Profile

type Profile int

Profile selects the color encoding used when compiling a Style. The zero value is NoColor.

const (
	// NoColor strips all SGR sequences. Compiled styles produce
	// unstyled output.
	NoColor Profile = iota

	// ANSI16 uses 4-bit color codes (8 base + 8 bright).
	ANSI16

	// ANSI256 uses 8-bit color codes (256 indexed colors).
	ANSI256

	// TrueColor uses 24-bit RGB color codes.
	TrueColor
)

type Style

type Style struct {
	// contains filtered or unexported fields
}

Style is the runtime form of a lipgloss.Style. It is profile-pinned and immutable after construction; safe for concurrent use.

Build a Style with Compile. The hot path is Render, which writes precomputed bytes into a Block.

func Compile

func Compile(s lipgloss.Style, profile Profile) *Style

Compile reads properties from s and produces a frozen, profile-pinned Style. Compile is one-shot per (style, profile); the returned *Style is reusable across many Render calls.

func (*Style) Render

func (s *Style) Render(content string) Block

Render encodes content using s and returns a Block describing the result. content may contain ANSI escape sequences; their display width is computed via ansi.StringWidth.

func (*Style) RenderBlock

func (s *Style) RenderBlock(b Block) Block

RenderBlock wraps a pre-measured Block inside the frame described by s and returns the resulting Block. The input Block must have lineOffsets populated (i.e. it must have been produced by Render or RenderTo).

func (*Style) RenderBlockTo

func (s *Style) RenderBlockTo(out *Block, b Block)

RenderBlockTo writes the frame-wrapped output into out, resetting it first. It skips text normalization, word-wrap, and per-line measurement — the input Block's dimensions are taken as ground truth.

func (*Style) RenderTo

func (s *Style) RenderTo(out *Block, content string)

RenderTo writes the rendered output into out, resetting it first. Reuses out's underlying buffer when possible — use this in hot loops to avoid allocation.

func (*Style) String

func (s *Style) String(content string) string

String renders content and returns the resulting bytes as a string. Equivalent to s.Render(content).String() but allocates only one Block.

Directories

Path Synopsis
examples
basic command
Basic stain example: compile a lipgloss style and render some content.
Basic stain example: compile a lipgloss style and render some content.
blending/linear-1d command
Linear 1D blending example: render a series of color gradient bars using lipgloss.Blend1D and stain to render each block.
Linear 1D blending example: render a series of color gradient bars using lipgloss.Blend1D and stain to render each block.
blending/linear-2d command
Linear 2D blending example: render named gradient boxes using lipgloss.Blend2D and stain.
Linear 2D blending example: render named gradient boxes using lipgloss.Blend2D and stain.
brightness command
Brightness example: progressive Lighten and Darken variations of base colors, rendered as block bars.
Brightness example: progressive Lighten and Darken variations of base colors, rendered as block bars.
color command
Color example: a bordered "moderately ripe banana?" dialog with styled text and active/inactive buttons.
Color example: a bordered "moderately ripe banana?" dialog with styled text and active/inactive buttons.
dashboard command
Dashboard example: a TUI-style layout combining multiple stain Blocks via JoinHorizontal, JoinVertical, and Place.
Dashboard example: a TUI-style layout combining multiple stain Blocks via JoinHorizontal, JoinVertical, and Place.
grid command
Grid example: render a 5×3 grid of styled cells using JoinHorizontal and JoinVertical.
Grid example: render a 5×3 grid of styled cells using JoinHorizontal and JoinVertical.
layout command
Layout example: a full-page TUI layout demonstrating tabs, title block with gradient background, dialog box, list columns, color grid, marmalade history paragraphs, and a status bar.
Layout example: a full-page TUI layout demonstrating tabs, title block with gradient background, dialog box, list columns, color grid, marmalade history paragraphs, and a status bar.
internal
encode
Package encode produces ANSI byte sequences for color and SGR attributes.
Package encode produces ANSI byte sequences for color and SGR attributes.

Jump to

Keyboard shortcuts

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