painted — Primitives and Blocks
Description: painted documentation

# 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 `Block`s 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