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:
Pixelsis the picture: a width, a height, and RGBA bytes, with nothing about cells or terminals.Pixels.blank,Pixels.from_pointsandwith_pointsbuild one from colors, and the constructor takes raw RGBA bytes from any decoder.HalfBlocksis a painter that draws aPixelsin 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 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)),),
),
),
),
),
)
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
HalfBlocksdoes, gives a canvas sized on only one axis its other axis by that ratio, sowidth(10)orheight(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, asmin-width: 0does for an<img>. - A
max_widthon a canvas in a column that doesn't stretch its children, such as one withalign_children_start, narrows it without shortening it. Set awidthinstead, 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
),
)
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)),
),
)
┌────────────────────────────────┐ ┌──────────────────────┐
│ ▄▄▄▀▀▀▀▀▀▀▀▄▄▄ │ │ ▄▀▀▀▀▄ │
│ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ │ │ ▀▀▀▀▀▀▀▀ │
│ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ │ │ ▀▀▀▀▀▀▀▀ │
│ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ │ │ ▀▀▀▀▀▀ │
│ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ │ └──────────────────────┘
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
└────────────────────────────────┘
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),))
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),))
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.
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.
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
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
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
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.
blank
classmethod
A width by height picture of pixels all color.
from_points
classmethod
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.
counterweight.painters.pixels.HalfBlocks
dataclass
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.
draw
The cells of a width by height box that show the picture, scaled by fit and placed by position.
pixels_at
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.