Skip to content

Paint, Paths, and Animation

The paint and animation building blocks: vector paths, the paint names every node shares, shadows, per-corner radii, group opacity, easing, and the colors text inputs, scroll views, and terminals paint. Everything here goes through Node.set, get, and animate.

Creating nodes

icon = window.create("path", data="M4,12 L10,18 L20,6", view_box=(0, 0, 24, 24),
                     width=24, height=24, stroke_color=(0x1C, 0x1B, 0x1F, 0xFF),
                     stroke_width=2)
panel = window.create("box", width=200, height="50%", fill=(0xFF, 0xFB, 0xFE, 0xFF),
                      corner_radius=12)
window.root.add_child(panel)
panel.add_child(icon)

window.create(kind, **props) makes a detached node — attach it with add_child — and applies props atomically, as set does: a bad property raises ValueError and nothing is created. Every kind, and every property, is listed in Nodes and Properties.

width and height take a number of pixels, "auto", or a percentage such as "50%" (a text node also "fit-content"), and read back as set.

Paths

Property Value
data SVG path data — the d attribute: M, L, H, V, C, S, Q, T, A, Z, absolute and relative
view_box (min_x, min_y, width, height) — the region data is drawn in, fitted uniformly and centered into the node's box (SVG's default xMidYMid meet); None draws data in node-local pixels
trim_start, trim_end 0.0–1.0 — the part of the stroke drawn, by length across every subpath in order
fill the path's fill
stroke_color, stroke_width the stroke, centered on the path with round caps and joins; its width stays in pixels however the view box scales

A Material Symbols SVG drops straight in: pass its d and its viewBox.

get("data") reads the path back normalized, not as you wrote it: relative commands become absolute, H and V become L, an arc A becomes cubic curves C, and coordinates are comma-separated. "m1 1 l2 2 h3 v4 z" reads back as "M1,1 L3,3 L6,3 L6,7 Z". The shape is the same. In a test, compare a read-back with a read-back (path.get("data") before and after), not with the string you set.

Trim is how progress indicators draw: animate trim_end from 0.0 to 1.0, or move both ends together for an indeterminate spinner.

Morphing: animating data to another path morphs between them. Any two closed paths or any two open paths morph — both are resampled by length, subpath by subpath, and closed contours are aligned by the best starting point, so a shape drawn from a different corner still morphs cleanly. Paths with different numbers of subpaths, or an open path against a closed one, switch at the halfway point instead. The last frame is exactly the target path.

SVG documents

An svg node (0.5.4) paints a whole SVG document. Give it the file's text or bytes (gzip-compressed .svgz too) and the node draws it as one scene, scaled uniformly and centred into its box (SVG's xMidYMid meet), inside its padding and clipped to it:

logo = window.create("svg", svg=open("logo.svg", "rb").read(), width=96, height=96)
logo.get("svg_size")        # (120.0, 120.0): the document's own width and height
logo.set(svg=other_text)    # swap the document

The document is parsed once, when svg is set, so a malformed one raises ValueError with the parser's message at that call and creates or changes nothing. Give the node a width and a height, or just one of them: a node sized on one side takes the other from the document's shape (its aspect_ratio follows the document, and follows a new document too, unless you set an aspect_ratio yourself). fill, stroke_color, corner_radius, opacity, blur and the rest paint for its box as on every node (a fill is the background behind the document).

Drawn: shapes, fills and strokes (caps, joins, miter limit, dashes, paint order), solid colours, linear and radial gradients with their spread method, patterns, group opacity, clip paths (nested ones too), masks (luminance and alpha, nested, with their rectangle), nested SVG <image>s, text, and a filter that is a single Gaussian blur or a single drop shadow (feDropShadow).

  • Text is turned into outlines from the fonts the engine has: Roboto (also the default, serif, and sans-serif family), the bundled mono face (monospace), anything tre.register_font registered, and, only when you turn it on with tre.set_system_fonts(True), the installed fonts. With system fonts off it looks the same on every machine. The outlines follow the fonts: registering a font or switching system fonts re-draws a document's text at the next frame or snapshot.
  • Masks have no mask layer in the renderer, so a masked group is drawn and then kept only where the mask's shapes are (a luminance mask's colours are converted to their luminance first). It costs one extra layer per mask.

Dropped without an error: raster <image>s the framework did not supply (below), every filter but a lone blur or drop shadow, and blend modes. The rest of the document still draws. A pattern that would need more than 2500 tiles is skipped.

Pictures inside a document. Tesserae Engine decodes no image format, here as everywhere, so a framework building on it is responsible for decoding the raster images (PNG, JPEG, GIF, WebP) an SVG refers to, and handing over pixels. It reads the document for its <image href="...">s, decodes each with its own imaging library, and passes straight-alpha RGBA8 pixels keyed by the href exactly as the document writes it:

photo = decode_png("assets/photo.png")          # your imaging library: (rgba, width, height)
node = window.create("svg", svg=text, width=240,
                     svg_images={"assets/photo.png": photo})
node.set(svg_images={**node.get("svg_images"), "other.jpg": other})   # or add later

An <image> whose href is not in svg_images is not drawn. The engine never opens a file, so a document cannot make it read one, and a data: URL holding a raster picture is not looked at either: a framework that wants those drawn rewrites the href to a key of its own and supplies the pixels. A nested SVG is drawn only from a data: URL; one referenced by path is not loaded. Each entry is checked like an image node's rgba (length, and a side of at most 8192), the images are uploaded once as GPU textures, svg_images read back gives what was set, and it stays across new svg documents until you change it.

get("svg") returns the source as it was given (a str or bytes), and svg_size the document's size.

svg_color is what currentColor resolves to, so a monochrome icon follows your theme: icon.set(svg_color=(0x1C, 0x1B, 0x1F, 0xFF)). It is None by default, which leaves currentColor black as the SVG standard says. It re-parses the document it has, takes effect together with a new svg given in the same call, and stays until you change it. A document whose root <svg> sets its own color keeps it.

Paint on every node

Property Value
fill (r, g, b, a) — a box's background, which also shows behind an image's transparent pixels and under a canvas, scroll view, virtual list, or terminal's content; a text's or a text input's glyph color; a path's fill
stroke_color, stroke_width the border, drawn inside the box; it never changes layout. On a path they are the path's outline instead
corner_radius one radius, or (top_left, top_right, bottom_right, bottom_left): rounds the background and the border, and clips an image to the rounded box (a scroll view and a virtual list clip their content to it). A path has no box, so it ignores it
opacity 0.0–1.0 — group opacity: the node and its whole subtree fade together, as one layer
shadows a list of (color, offset_x, offset_y, blur, spread) — CSS box-shadow's model, the first listed on top
blur (0.5.4) a number ≥ 0: a Gaussian blur of the node and its whole subtree, as a standard deviation in pixels
backdrop_blur (0.5.4) a number ≥ 0: frosted glass — what is behind the node, blurred, inside its box
blend_mode (0.5.4) how the node and its subtree mix with what is behind: "normal" (the default) or a CSS mix-blend-mode

Every kind paints these for its own box (0.5.1). Before, a text, text input, image, canvas, scroll view, virtual list, or terminal accepted stroke_color, stroke_width, or corner_radius (and the last four a fill) and drew nothing. Only a box showed them all.

Colors are (r, g, b, a) tuples of ints, and a color's own alpha renders: a fill of (255, 0, 0, 128) is half-transparent red. get returns exactly the tuple that was set.

corner_radius reads back as a number while all four corners share one radius, and as a 4-tuple once they're set (or animated) separately; setting a number makes them uniform again. A shadow under a node with differing corner radii uses their mean.

A design system's elevation levels are shadow lists — Material Design 3's are a key and an ambient shadow per level:

card.set(shadows=[((0, 0, 0, 77), 0, 1, 2, 0), ((0, 0, 0, 38), 0, 1, 3, 1)])

Gradients

A box's fill can be a tre.Gradient (0.5.4) instead of a colour: a ramp across the box, linear, radial or sweeping around a point. A box (window.create("box"), a window's root), a path and a text node take one as fill (a text node's gradient paints its glyphs, across the node's box); on a text input, fill is a colour. Elsewhere a gradient is accepted in place of a colour too, each time spanning the shape's own bounds:

  • stroke_color on any node: its border, over the node's box (a path's stroke: over the stroke's bounds). A gradient stroke does not animate; set it again.
  • A canvas Painter's fill_rect, fill_circle and stroke_path: over the rect, the circle's bounding square, or the path's bounds.
from tre import Gradient

card.set(fill=Gradient.linear([(0x67, 0x50, 0xA4, 0xFF), (0x21, 0x00, 0x5D, 0xFF)], angle=135))
glow.set(fill=Gradient.radial([(255, 255, 255, 200), (255, 255, 255, 0)], radius=0.8))
dial.set(fill=Gradient.sweep([red, yellow, green, red], start=0))
Constructor Runs
Gradient.linear(stops, angle=180) along a line through the box's centre at angle degrees: 0 points up, 90 right, 180 down (the default). The line spans the box, so the first and last stops land on its corners
Gradient.radial(stops, center=(0.5, 0.5), radius=1.0) outward from center, a fraction of the box; radius is a fraction of the half-diagonal, so 1.0 reaches the far corner of a centred gradient
Gradient.sweep(stops, center=(0.5, 0.5), start=0) around center, clockwise from start degrees past up

stops are colours, spaced evenly from 0 to 1, or (offset, color) pairs with offsets from 0 to 1 that don't decrease. There must be at least two. A stop's alpha is honoured, so a gradient can fade to transparent. A gradient follows its box as layout resizes it, and follows its rounded corners.

A Gradient is immutable. node.get("fill") returns it while one is set, and setting fill to a colour replaces it. Animating fill to a gradient of the same kind and number of stops interpolates the colours, offsets, angle, centre and radius; animating from a flat colour fades the gradient in from that colour. Animating between unlike gradients, or from a gradient to a colour, raises ValueError: set fill instead.

Blur, frosted glass, and blend modes

Three effects (0.5.4), all acting on a node as a group, like opacity:

card.set(blur=3)                        # the card and everything in it, soft
panel.set(fill=(255, 255, 255, 60), backdrop_blur=12, corner_radius=16)  # frosted glass
highlight.set(blend_mode="screen")      # lighten what is behind
  • blur is the standard deviation of a Gaussian blur, in the node's own pixels, so it scales with the display. The blur spreads the node's pixels about three deviations past its box, and that margin is damaged and redrawn with it. It animates (node.animate("blur", 0, 300)).
  • backdrop_blur blurs everything painted behind the node and shows it inside the node's box, clipped to its rounded corners. The node's own fill draws over it, so a translucent white fill makes frosted glass. Cost follows the box, not the window: the content behind is drawn again inside the box and the blur's reach, then blurred. It animates. A node behind another frosted node is drawn blurred in that one's backdrop, to a depth of two.
  • blend_mode is one of normal, multiply, screen, overlay, darken, lighten, color_dodge, color_burn, hard_light, soft_light, difference, exclusion, hue, saturation, color, luminosity. The node and its subtree are drawn as one layer and mixed with what is behind it.

Not available: colour filters as node properties, and alpha masks (a mask is a shape, below). For saturate, brightness, contrast, grayscale, hue rotate, invert and sepia use Shader.filter, a ready-made effect shader.

Masks (0.5.6)

mask clips a node and everything in it to a shape that is not a rounded box:

avatar.set(mask="circle")                                  # the circle inside the box
card.set(mask={"rounded": (24, 4, 24, 4)})                 # a radius per corner
blob.set(mask={"path": "M5 0 L10 10 L0 10 Z",             # any SVG path data,
               "view_box": (0, 0, 10, 10)})                #   fitted into the box
avatar.set(mask=None)                                      # clear
  • "circle" is the circle whose diameter is the shorter side of the box, centred. A non-square box gets a circle, not an ellipse.
  • {"rounded": r} takes a number or a (top_left, top_right, bottom_right, bottom_left) tuple. It is independent of the node's own corner_radius.
  • {"path": data, "view_box": (x, y, w, h)} fits the path into the box the way a path node's view_box does (scaled evenly and centred). get("mask") returns path data in tre's own syntax (M0,0 L10,0), not the spelling you gave.
  • The mask is in the node's own coordinates, so it follows the node's translate_*, scale and rotation_deg. It is antialiased at the edge.
  • It clips the node's own paint, its backdrop_blur, and all of its children, inside the node's opacity. (clip_children clips only the children.)
  • Hit testing ignores a mask, as it ignores clip_children and rounded corners: a press on a clipped-away corner still reaches the node. Use hit_testable or an event check if that matters.
  • It is a shape, not an alpha image: no soft edges, and animating between two masks is not supported yet.

Animating

button.animate("fill", (0x67, 0x50, 0xA4, 0xFF), 200, easing=(0.2, 0.0, 0.0, 1.0))
button.get("fill")         # the color on screen right now
button.get_target("fill")  # where it's heading
button.stop_animation("fill")

animate(name, to, duration_ms=0, easing=None, on_complete=None) eases a property from its current value, so animating again mid-flight never jumps. easing is "linear" (the default), a spring ("spring" or ("spring", bounce), 0.5.4; see the animation guide), or a cubic bezier (x1, y1, x2, y2) exactly as CSS cubic-bezier() takes it — MD3's named curves are bezier values, e.g. emphasized decelerate is (0.05, 0.7, 0.1, 1.0). on_complete runs once when the value arrives; an animation replaced by another, or stopped, never calls it.

Animatable: fill, stroke_color, stroke_width, opacity, blur, backdrop_blur, corner_radius (a number or a 4-tuple), shadows (lists of different lengths fade the extra shadows in or out), the transform parts translate_x, translate_y, scale, and rotation_deg, a scroll view's scroll_offset, and a path's data, trim_start, and trim_end. Colors interpolate component-wise in sRGB.

get(name) returns the value on screen, mid-animation included; get_target(name) returns where a running animation is heading, the same as get when nothing animates it. stop_animation(name) stops where it is.

Text inputs, scroll views, and terminals

Tesserae Engine paints no color you can't set:

Kind Property Value
text input placeholder the hint shown while the text is empty
text input placeholder_fill its color; None is the text color at 60%
text input caret_color None is the text color; animates (0.5.6)
text input caret_visible, caret_width, caret_shape, caret_blink (0.5.6) draw the caret or not, its width, "bar"/"block"/"underline", and its blink period in milliseconds; see Text
text input selection_fill None is the text color at 30%
text input obscured a password field: every character shows as a bullet, and its text never leaves through copy or cut
scroll view scrollbar_fill the thumb's color; None is the default
scroll view scrollbar_width the thumb's thickness, painted and grabbed
terminal palette a dict with any of ansi (16 colors), foreground, background, cursor, selection — keys you leave out keep their colors

A terminal resolves each cell's color against its palette when it paints, so a new palette recolors what's already on screen. Tesserae Engine draws no focus ring on a node you build: show focus yourself from the focus and unfocus events.