Skip to content

Canvas

A Canvas is a leaf element whose content box its painter draws, cell by cell, for pictures, plots, game boards and anything else that isn't text. A Text of chunks can draw the same cells, but layout measures and wraps a Text every frame, which a picture never needs. A canvas skips all of that: its painter says how big it is, and draws whatever box it ends up with.

This page covers drawing pixels, how a canvas is sized, loading an image, mouse input, and writing a painter of your own.

Drawing Pixels

counterweight.painters.pixels splits a picture from how it's drawn:

  • Pixels is the picture: a width, a height, and RGBA bytes, with nothing about cells or terminals. Pixels.blank, Pixels.from_points and with_points build one from colors, and the constructor takes raw RGBA bytes from any decoder.
  • HalfBlocks is a painter that draws a Pixels in cells, two pixels to a cell, one above the other, as ▀ with the top pixel as its foreground and the bottom one as its background. A cell is about twice as tall as it is wide, so the pixels come out about square.
SKY_TOP = Color.from_hex("#1e1b4b")
SKY_BOTTOM = Color.from_hex("#f97316")
SUN = Color.from_hex("#fde047")
SEA = Color.from_hex("#0e7490")


def blend(a: Color, b: Color, t: float) -> Color:
    return Color(*(round(x + (y - x) * t) for x, y in zip(a, b, strict=True)))


def sunset(width: int, height: int) -> Pixels:
    """A sky that brightens toward the horizon, a sun setting on it, and the sea below."""
    horizon = height * 3 // 4
    radius = width / 4

    def color(x: int, y: int) -> Color:
        if y >= horizon:
            return SEA
        if (x + 0.5 - width / 2) ** 2 + (y + 0.5 - horizon) ** 2 < radius**2:
            return SUN
        return blend(SKY_TOP, SKY_BOTTOM, y / horizon)

    rgba = b"".join(bytes((*color(x, y), 255)) for y in range(height) for x in range(width))
    return Pixels(width, height, rgba)


SUNSET = sunset(16, 16)


@component
def picture() -> Canvas:
    return Canvas(painter=HalfBlocks(SUNSET), style=border)

A sunset drawn with half blocks

┌──────────────────┐
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
└──────────────────┘

A canvas compares its painter with == to the one it had last frame, and doesn't draw again while it's equal and its box is the same size. Two HalfBlocks are equal when their pixels' bytes, fit and position are, so a component that holds a Pixels in state can build its HalfBlocks in every render, and the canvas is drawn again only when the pixels or its box change. Changing a picture means making a new Pixels, as with_points does.

Sizing

A canvas is sized as HTML's replaced elements (<img>, <canvas>) are. Its painter's natural size is its intrinsic size, which it takes on each axis that its style and its parent's layout leave undetermined, as an <img> takes its picture's. A HalfBlocks is as wide as its picture and half as tall, rounded up.

def labeled(label: str, canvas: Canvas) -> Div:
    return Div(style=col | align_children_start, children=(Text(content=label), canvas))


@component
def sizing() -> Div:
    return Div(
        style=row | gap(2) | align_children_start,
        children=(
            labeled("natural size", Canvas(painter=HalfBlocks(SUNSET), style=border)),
            labeled("width(10)", Canvas(painter=HalfBlocks(SUNSET), style=border | width(10))),
            labeled("height(4)", Canvas(painter=HalfBlocks(SUNSET), style=border | height(4))),
            Div(
                style=col | width(12),
                children=(
                    Text(content="min_width(0)"),
                    Div(
                        style=row,
                        children=(Canvas(painter=HalfBlocks(SUNSET), style=border | min_width(0)),),
                    ),
                ),
            ),
        ),
    )

Canvases at their natural size, with a width, with a height, and shrunk

natural size        width(10)   height(4)  min_width(0)           
┌────────────────┐  ┌────────┐  ┌────┐     ┌──────────┐           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  │▀▀▀▀▀▀▀▀│  │▀▀▀▀│     │▀▀▀▀▀▀▀▀▀▀│           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  │▀▀▀▀▀▀▀▀│  │▀▀▀▀│     │▀▀▀▀▀▀▀▀▀▀│           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  │▀▀▀▀▀▀▀▀│  └────┘     │▀▀▀▀▀▀▀▀▀▀│           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  │▀▀▀▀▀▀▀▀│             │▀▀▀▀▀▀▀▀▀▀│           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  └────────┘             │▀▀▀▀▀▀▀▀▀▀│           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                         └──────────┘           
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                                                
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                                                
└────────────────┘                                                
  • A size from the style, or from a parent that stretches or flexes it, wins on its axis, and the painter draws at the size the canvas's content box ends up.
  • A painter that keeps its aspect ratio, as HalfBlocks does, gives a canvas sized on only one axis its other axis by that ratio, so width(10) or height(4) alone keeps the picture's proportions. The ratio is between content boxes, so a border or padding doesn't distort it.
  • In a flex row, a canvas shrinks no narrower than its intrinsic width, as an <img> doesn't, so a canvas wider than its row overflows it and draws over what's beside it. min_width(0) lets it shrink, as min-width: 0 does for an <img>.
  • A max_width on a canvas in a column that doesn't stretch its children, such as one with align_children_start, narrows it without shortening it. Set a width instead, or let the column stretch it.

Fitting a Picture to Its Box

When the box isn't the picture's natural size, HalfBlocks's fit scales the picture as CSS's object-fit does, and position places it in the space left over as object-position does. fit is on the painter rather than the canvas's style, because only a painter with pixels has a picture to scale.

FITS: tuple[Fit, ...] = ("fill", "contain", "cover", "none", "scale-down")


@component
def fits() -> Div:
    return Div(
        style=row | gap(1),
        children=tuple(
            labeled(f'fit="{fit}"', Canvas(painter=HalfBlocks(SUNSET, fit=fit), style=border | size(22, 6)))
            for fit in FITS
        ),
    )

The same picture with each fit

fit="fill"             fit="contain"          fit="cover"            fit="none"             fit="scale-down"          
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐    
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │      ▀▀▀▀▀▀▀▀      │ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  │ │      ▀▀▀▀▀▀▀▀      │    
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │      ▀▀▀▀▀▀▀▀      │ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  │ │      ▀▀▀▀▀▀▀▀      │    
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │      ▀▀▀▀▀▀▀▀      │ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  │ │      ▀▀▀▀▀▀▀▀      │    
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │      ▀▀▀▀▀▀▀▀      │ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│ │  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  │ │      ▀▀▀▀▀▀▀▀      │    
└────────────────────┘ └────────────────────┘ └────────────────────┘ └────────────────────┘ └────────────────────┘    
  • "fill", the default, stretches the picture to the box on each axis.
  • "contain" scales it to fit inside the box, keeping its proportions.
  • "cover" scales it to cover the box, keeping its proportions, and cuts off what overflows.
  • "none" leaves it at its natural size, cut off where it overflows.
  • "scale-down" is whichever of "none" and "contain" is smaller.

position is the fractions of the space left over on each axis to put to the left of and above the picture, (0.5, 0.5) by default, which centers it. It places the picture in whole pixels, so a picture can start halfway down a cell.

Each cell shows the pixels nearest the middle of its two halves, which is fast but rough when shrinking a photo a long way. For a smoother picture, scale it to the size you'll show it at first, such as with Pillow's Image.thumbnail.

Transparency

A pixel less than half opaque is transparent, and so is any part of the box the picture doesn't cover. A transparent half of a cell shows the canvas's background: its text_bg if its style sets one, and its content_color otherwise, so a canvas with only a content_color shows it everywhere the picture doesn't.

Loading an Image

Pixels.from_image takes a Pillow Image, or anything else with Pillow's size, convert and tobytes. Decoding costs as much as the image has pixels, so do it once, before the app starts, or in an effect that sets state, never in a render.

# Decode the image once, before the app starts, rather than in a render.
LOGO = Pixels.from_image(Image.open(Path(__file__).parent.parent / "assets" / "favicon.png"))


@component
def image() -> Div:
    return Div(
        style=row | gap(2) | align_children_start,
        children=(
            Canvas(painter=HalfBlocks(LOGO), style=border | content_color("slate", 700)),
            Canvas(painter=HalfBlocks(LOGO, fit="contain"), style=border | size(24, 6) | content_color("slate", 700)),
        ),
    )

An image with transparent corners, at its natural size and contained in a smaller box

┌────────────────────────────────┐  ┌──────────────────────┐  
│         ▄▄▄▀▀▀▀▀▀▀▀▄▄▄         │  │        ▄▀▀▀▀▄        │  
│      ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄      │  │       ▀▀▀▀▀▀▀▀       │  
│    ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄    │  │       ▀▀▀▀▀▀▀▀       │  
│  ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄  │  │        ▀▀▀▀▀▀        │  
│ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ │  └──────────────────────┘  
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │                            
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                            
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                            
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                            
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│                            
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │                            
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │                            
│  ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  │                            
│    ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀    │                            
│      ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀      │                            
│         ▀▀▀▀▀▀▀▀▀▀▀▀▀▀         │                            
└────────────────────────────────┘                            

Mouse Input

A canvas takes an on_input handler as any element does, and mouse events arrive in cells, as positions on the screen. HalfBlocks.pixels_at turns a cell of a box into the pixels of the picture that cell shows, scaled and placed by fit and position, so a paint program sets the pixels under the mouse:

BLANK = Pixels.blank(24, 16, Color.from_name("black"))
INK = Color.from_hex("#fbbf24")


@component
def paint() -> Div:
    pixels, set_pixels = use_state(BLANK)
    painter = HalfBlocks(pixels)

    def on_input(event: InputEvent, receiver: Receiver) -> InputResponse:
        match event:
            case MouseDown(absolute=a, button=1) | MouseMoved(absolute=a, button=1):
                content = receiver.rects.content
                under = painter.pixels_at(receiver.relative(a), content.width, content.height)
                set_pixels(lambda p: p.with_points(dict.fromkeys(under, INK)))
                return ()
            case _:
                return None

    return Div(style=col | align_children_start, children=(Canvas(painter=painter, style=border, on_input=on_input),))

A line drawn by dragging across the canvas

┌────────────────────────┐  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│  
└────────────────────────┘  

Writing a Painter

A painter is any object with the three members of Painter: a natural_size in cells, whether it keeps_aspect_ratio, and draw(width, height), which returns rows of StyledGraphemes for a box of that size, top to bottom, each drawn rightward from the box's left edge. Cells past the box's edges are cut off, and any part of the box the rows don't reach shows the canvas's background. Whatever a cell's style leaves unset comes from the canvas, as it does for a Text's chunks, so a painter never needs the canvas's style.

A painter that takes whatever box it gets has a natural size of zero, and is sized with its style. This ruler draws a tick in every column of its box and a label in every tenth, however wide the box is:

@dataclass(frozen=True, slots=True)
class Ruler:
    """Draws a ruler as wide as whatever box it's given: a tick on every column, and a label on every tenth."""

    natural_size: tuple[int, int] = (0, 2)
    keeps_aspect_ratio: bool = False

    def draw(self, width: int, height: int) -> Rows:
        ticks = tuple(StyledGrapheme(grapheme="┴" if x % 10 == 0 else "╵" if x % 5 == 0 else "·") for x in range(width))
        labels = "".join(f"{x:<10}" for x in range(0, width, 10))[:width]
        label_style = CellStyle(foreground=Color.from_hex("#fbbf24"))
        return ticks, tuple(StyledGrapheme(grapheme=c, style=label_style) for c in labels)


@component
def ruler() -> Div:
    return Div(style=col, children=(Canvas(painter=Ruler(), style=border),))

A ruler as wide as its box

┌──────────────────────────────────────────────────────────────┐
│┴····╵····┴····╵····┴····╵····┴····╵····┴····╵····┴····╵····┴·│
│0         10        20        30        40        50        60│
└──────────────────────────────────────────────────────────────┘

Since a canvas draws again only when its painter isn't equal to the last one, draw must depend only on the painter and the size it's given. A frozen dataclass is the easiest way to get that: two built from the same values are equal, so a component can build its painter in every render.

API

counterweight.elements.Canvas dataclass

Canvas(
    *,
    painter: Painter,
    style: Style = _DEFAULT_STYLE,
    on_input: InputHandler | None = None,
    handle: ElementHandle | None = None,
)

A leaf element whose content box its painter draws, cell by cell, instead of laying out text.

It's sized as HTML's replaced elements (<img>, <canvas>) are: its painter's natural_size is its intrinsic size, which it takes on each axis that its style and its parent's layout leave undetermined, and draw is called with the size its content box ends up. A painter that keeps its aspect ratio gives a canvas sized on only one axis the other by that ratio. In a flex row, a canvas shrinks no narrower than its intrinsic width, as an <img> doesn't, unless its style sets min_width(0).

A background that its painter leaves unset is the canvas's text_style background if its style sets one, and its content_color otherwise, so a cell the painter leaves transparent matches the rest of the box.

painter instance-attribute

painter: Painter

style class-attribute instance-attribute

style: Style = _DEFAULT_STYLE

on_input class-attribute instance-attribute

on_input: InputHandler | None = None

handle class-attribute instance-attribute

handle: ElementHandle | None = None

The element handle that names this element for controls such as ScrollTo, and for use_scroll.

children property

children: tuple[Component | AnyElement, ...]

counterweight.elements.Painter

What draws a Canvas's content box.

A painter is compared with == to the one its canvas had in the last frame, and draw isn't called again while it's equal and the box is the same size, so what draw returns must depend only on the painter and the size it's given. A frozen dataclass built from the same values in each render is equal to the last one, so a component can build its painter in every render.

natural_size property

natural_size: tuple[int, int]

The canvas's intrinsic size in cells, as (width, height), which it takes on each axis that its style and its parent's layout leave undetermined.

keeps_aspect_ratio property

keeps_aspect_ratio: bool

Whether a canvas sized on one axis takes its size on the other from the ratio of natural_size, as an <img> with only a width keeps its picture's proportions. A natural size of zero on either axis has no ratio to keep.

draw

draw(width: int, height: int) -> Rows

The rows of cells to show in a width by height content box, top to bottom, each drawn rightward from the box's left edge.

Each cell's style is resolved against the canvas's, so whatever a cell leaves unset comes from the canvas: its foreground from the text_style, and its background as the Canvas describes. Cells past the box's edges are cut off, and any part of the box the rows don't reach shows the canvas's background. It isn't called for a box with no cells.

counterweight.painters.pixels.Pixels dataclass

Pixels(width: int, height: int, rgba: bytes)

A picture: a width by height grid of pixels, as RGBA bytes, row by row from the top left, four bytes (red, green, blue and alpha) to a pixel. It raises ValueError if the bytes aren't four for each pixel.

Two Pixels are equal when their bytes are, which a canvas compares its painter by from frame to frame, so changing a picture means making a new one, as with_points does. Build or decode one outside of rendering, such as when the app starts or in an effect that sets state, since it costs as much as the picture has pixels.

width instance-attribute

width: int

height instance-attribute

height: int

rgba class-attribute instance-attribute

rgba: bytes = field(repr=False)

__post_init__

__post_init__() -> None

blank classmethod

blank(width: int, height: int, color: Color) -> Pixels

A width by height picture of pixels all color.

from_points classmethod

from_points(
    width: int,
    height: int,
    points: Mapping[Position, Color],
    default: Color,
) -> Pixels

A width by height picture of default pixels, but for the points given their own colors.

from_image classmethod

from_image(image: SupportsRGBA) -> Pixels

The pixels of image, such as a Pillow Image, converted to RGBA.

with_points

with_points(points: Mapping[Position, Color]) -> Pixels

This picture with the pixel at each of points set to its color, which raises ValueError for a point outside the picture.

counterweight.painters.pixels.HalfBlocks dataclass

HalfBlocks(
    pixels: Pixels,
    fit: Fit = "fill",
    position: tuple[float, float] = (0.5, 0.5),
)

A Canvas painter that draws pixels two to a cell, one above the other, as a half block (▀) whose foreground is the top pixel and whose background is the bottom one. A cell is about twice as tall as it is wide, so the pixels are about square, and its natural size is the picture's width by half its height, rounded up.

In a box of another size, fit scales the picture as CSS's object-fit does, and position places it as object-position does, as the fractions of the space left over on each axis to put to its left and above it, so (0.5, 0.5), the default, centers it, and (0, 0) puts it in the top left corner. A picture larger than its box is cut off.

Each cell shows the pixels nearest the middle of its two halves. A pixel less than half opaque is transparent, and leaves its half of the cell to show the canvas's background, as does a half the picture doesn't cover.

pixels instance-attribute

pixels: Pixels

fit class-attribute instance-attribute

fit: Fit = 'fill'

position class-attribute instance-attribute

position: tuple[float, float] = (0.5, 0.5)

natural_size property

natural_size: tuple[int, int]

keeps_aspect_ratio property

keeps_aspect_ratio: bool

draw

draw(width: int, height: int) -> Rows

The cells of a width by height box that show the picture, scaled by fit and placed by position.

pixels_at

pixels_at(
    cell: Position, width: int, height: int
) -> tuple[Position, ...]

The pixels of the picture shown in cell of a width by height box, from its top half to its bottom half, such as for a paint program to set the pixels under the mouse. There's one pixel if both halves show the same one, and none if the picture doesn't cover the cell.

sources

sources(
    width: int, height: int
) -> tuple[tuple[int | None, ...], tuple[int | None, ...]]

For each pixel column and pixel row of a width by height box, two pixel rows to a cell, the column or row of the picture it shows, or None where the picture doesn't cover it.

counterweight.painters.pixels.SupportsRGBA

An image that converts to RGBA bytes, as a Pillow Image does.

size property

size: tuple[int, int]

The image's width and height, in pixels.

convert

convert(mode: str) -> SupportsRGBA

The image in another mode, such as "RGBA".

tobytes

tobytes() -> bytes

The image's pixels, row by row.