painted

Primitives and Blocks

painted is built from a small set of immutable “render layer” value types:

  • Primitives: Style, Cell, Span, Line
  • Rectangles: Block

These are the inputs to every higher-level feature (composition, buffers, TUI, widgets).

See also:

  • docs/ARCHITECTURE.md: stack + data flow (../ARCHITECTURE.md)
  • docs/PRIMITIVES.md: quick reference (../PRIMITIVES.md)

Style

Style is an immutable bundle of attributes (colors, bold/underline/etc). Styles combine via merge() (overlay wins).

@dataclass(frozen=True)
class Style:
    """Immutable text style with color and attribute flags."""

    fg: Color = None
    bg: Color = None
    bold: bool = False
    italic: bool = False
    underline: bool = False
    reverse: bool = False
    dim: bool = False

    def merge(self, other: Style) -> Style:
        """Combine styles. `other` overrides non-None/non-False fields."""
        return Style(
            fg=other.fg if other.fg is not None else self.fg,
            bg=other.bg if other.bg is not None else self.bg,
            bold=other.bold or self.bold,
            italic=other.italic or self.italic,
            underline=other.underline or self.underline,
            reverse=other.reverse or self.reverse,
            dim=other.dim or self.dim,
        )
    def merge(self, other: Style) -> Style:

Cell

Cell is the atom: one character + one style. Most code manipulates Blocks rather than individual cells.

@dataclass(frozen=True)
class Cell:
    """Atomic display unit: a single character with style."""

    char: str
    style: Style

    def __post_init__(self):
        if len(self.char) != 1:
            raise ValueError(f"Cell char must be a single character, got {self.char!r}")

Span and Line

Span is “text + style” with display width (wide-char aware). Line is a tuple of spans that can paint into a BufferView or convert into a Block.

@dataclass(frozen=True, slots=True)
class Span:
    """A run of text with a single style."""

    text: str
    style: Style = Style()

    @property
    def width(self) -> int:
        """Display width, accounting for wide characters."""
        w = wcswidth(self.text)
        if w < 0:
            # Fallback for strings containing non-printable chars
            return len(self.text)
        return w
@dataclass(frozen=True, slots=True)
class Line:
    """A sequence of spans forming a single line of styled text."""

    spans: tuple[Span, ...] = ()
    style: Style = Style()

    @classmethod
    def plain(cls, text: str, style: Style = Style()) -> Line:
        """Create a Line from a single unstyled (or uniformly styled) string."""
        return cls((Span(text, style),))

    @property
    def width(self) -> int:
        """Total display width across all spans."""
        return sum(s.width for s in self.spans)

    def paint(self, view: BufferView, x: int, y: int) -> None:
        """Render spans into a BufferView, merging base style onto each span."""
        col = x
        for span in self.spans:
            merged = self.style.merge(span.style)
            view.put_text(col, y, span.text, merged)
            col += span.width

    def truncate(self, max_width: int) -> Line:
        """Return a new Line truncated to max_width display columns."""
        remaining = max_width
        kept: list[Span] = []
        for span in self.spans:
            sw = span.width
            if sw <= remaining:
                kept.append(span)
                remaining -= sw
            else:
                # Cut this span character by character
                chars: list[str] = []
                used = 0
                for ch in span.text:
                    cw = wcswidth(ch)
                    if cw < 0:
                        cw = 1
                    if used + cw > remaining:
                        break
                    chars.append(ch)
                    used += cw
                if chars:
                    kept.append(Span("".join(chars), span.style))
                break
        return Line(spans=tuple(kept), style=self.style)

    def to_block(self, width: int) -> "Block":
        """Convert this Line to a Block of the given width.

        Builds cells directly from spans, merging Line style onto each span.
        Pads with empty cells if Line is shorter than width.
        Truncates if Line is longer than width.
        """
        from .block import Block

        cells: list[Cell] = []
        for span in self.spans:
            merged = self.style.merge(span.style)
            for ch in span.text:
                if len(cells) >= width:
                    break
                cells.append(Cell(ch, merged))
            if len(cells) >= width:
                break

        # Pad to width
        while len(cells) < width:
            cells.append(EMPTY_CELL)

        return Block([cells], width)

Block

Block is an immutable rectangle of styled cells with known width/height. It’s the unit of composition.

Instead of embedding the full Block implementation here (it’s larger than the other primitives), the guide pins the public construction surface:

class Wrap(Enum):
    NONE = "none"        # single line, truncate at width
    CHAR = "char"        # break at any character
    WORD = "word"        # break at word boundaries
    ELLIPSIS = "ellipsis"  # truncate with "…"
class Block:
    @staticmethod
    def text(content: str, style: Style, *, width: int | None = None,
             wrap: Wrap = Wrap.NONE) -> Block:
    @staticmethod
    def empty(width: int, height: int, style: Style = Style()) -> Block:

Why this matters

painted deliberately pushes complexity up the stack:

  • These types are immutable values → safe to share and cache.
  • Higher-level systems (buffers, layers, widgets) can treat rendering as pure transformation: state → blocks.

Demo output

                            
  attributes                
                            
  bold             deploy OK
  italic           deploy OK
  underline        deploy OK
  dim              deploy OK
  reverse          deploy OK
                            
  foreground                
                            
  red                       
  green                     
  blue                      
  yellow                    
  cyan                      
  magenta                   
                            
  background                
                            
  red                       
  green                     
  blue                      
  yellow                    
  cyan                      
  magenta                   
                            
  combinations              
                            
  bold + green     deploy OK
  dim + italic     deploy OK
  reverse + cyan   deploy OK
  bold + underline deploy OK
  bg + bold        deploy OK
                            
  merge                     
                            
  base             deploy OK
  + overlay        deploy OK
  = merged         deploy OK