Skip to content

Nodes and Layout

Everything on screen is a node in a window's tree. You create nodes, attach them under one another, and set their layout properties; Tesserae Engine lays the tree out with flexbox or CSS Grid (taffy) whenever it changes, and paints it every frame.

Windows and the app

from tre import App, Window

window = Window(width=640, height=400, title="Inbox")
# ... build the tree under window.root ...

app = App()
app.add_window(window)
app.run()

A Window owns one tree. Its root is a flex row with 16px of padding and 16px gaps, sized to the window; restyle it like any node, with window.root.set(...). App.run() opens every added window and blocks until all of them close. run(max_frames=N) stops each window after N frames, which is how the examples exit on their own.

A window's size follows the OS window: listen for the resize window event to react when the user resizes it. window.resize(width, height) changes it from code.

Creating and attaching nodes

window.create(kind, **props) makes a node and sets its properties in one atomic step: a bad property raises ValueError and nothing is created.

Kind What it is
"box" A layout container that can paint a fill, a stroke, rounded corners, and shadows
"text" Shaped text — see Text
"text_input" Editable text — see Text
"image" Decoded RGBA8 pixels — see Painting
"path" A vector path — see Painting
"canvas" Immediate-mode drawing — see Painting
"scroll_view" A clipped, scrollable viewport — see below
"virtual_list" A list that builds only its visible rows — see below
"terminal" A terminal emulator running a shell — see Text

A new node is detached. Attach it with add_child, which appends, or insert_child(index, child):

panel = window.create("box", flex_direction="vertical", gap=8, padding=12, width=240)
title = window.create("text", text="Inbox", font_size=22, width=200, height=28)
search = window.create("text_input", placeholder="Search", width=216, height=32)
panel.add_child(title)
panel.add_child(search)
window.root.add_child(panel)

Every property is on Nodes and Properties.

Flex layout

A node lays out its children along flex_direction — "horizontal" (the default) or "vertical" — separated by gap and inset by padding. align_items places them across that axis and justify_content along it:

toolbar = window.create("box", width="100%", height=48, padding=8, gap=8,
                        align_items="center", justify_content="space_between")

Sizes are pixels, "auto", or a percentage of the parent: width="50%". A text node can also be "fit-content": the size of its text (see Sizing text to its content).

Mixed units (0.5.6.2). width and height also take a percentage plus pixels, written "calc(100% - 16)", "100% - 16" or "33% + 10" (a space around each + and -, as in CSS; px is optional): the whole width except a strip, a third and ten pixels. The percentage is taken of the same box a plain "50%" is: the parent's content box for a node in the flow, its padding box for an absolute one. It follows the parent when the parent is resized, and reads back as "calc(100% - 16px)".

bar = window.create("box", width="100% - 16", height=40)

The tree resolves it after laying out the parent and then lays out again, so it costs a second layout pass on the frames where a parent changed size, and only for trees that use it. Limits: it is for width and height, not min_* or max_*; in a grid it is taken of the grid container, not the item's cell; and a size its own parent depends on (a row as wide as its children, with a child that is "100% - 16" of the row) has no answer, so it is left auto and a warning is logged. For a strip stretched between two edges of an absolute node, left and right (below) say it without a calculation. As a flex child, a node can grow into spare room with flex_grow, shrink with flex_shrink, and start from flex_basis; align_self overrides its parent's align_items for it alone. A sidebar that keeps its width while the content takes the rest:

sidebar = window.create("box", width=200, flex_shrink=0)
content = window.create("box", flex_grow=1)

padding and margin take one value for every side, or set sides one at a time with padding_top, margin_left, and so on. A margin of "auto" takes up the free space on that side, which pushes a node to the far end of its row.

min_width, max_width, min_height, and max_height bound a size, and aspect_ratio fixes width over height. flex_wrap="wrap" lets children flow onto more lines, and align_content spaces those lines. The property reference lists every layout property and its values.

Grid layout

display="grid" lays a node's children out in rows and columns instead of one line, so their edges line up across rows — a settings form of labels and fields, a gallery of equal cells, a dashboard of tiles. The columns and rows are track lists: pixels, a percentage, "auto", a share of the free space ("1fr"), "minmax(min, max)", and "repeat(n, tracks)", as a string or a list; a bare number is one track of that many pixels (grid_auto_rows=96):

form = window.create("box", display="grid", width="100%",
                     grid_template_columns="auto 1fr", row_gap=8, column_gap=12)
for label, field in rows:
    form.add_child(label)   # column 1
    form.add_child(field)   # column 2, as wide as the rest of the form

Children fill the cells in order, a row at a time (grid_auto_flow="column" fills columns instead). A child can be placed with grid_column and grid_row: a start line (2; negative lines count from the end, so -2 is the last column's), a span ("span 2"), or both ends ("1 / 3"). A gallery whose cells wrap to fit the width:

gallery = window.create("box", display="grid", width="100%", gap=8,
                        grid_template_columns="repeat(auto_fill, minmax(96, 1fr))")

row_gap and column_gap space the rows and columns apart (gap sets both), and justify_items/justify_self place a child across its cell's width the way align_items/align_self do across its height. The full list is in Node properties.

Absolute positioning

position="absolute" takes a node out of the flow and places it at x/y from its parent's padding box — for a badge over a corner, a drag preview, or a scrim that fills its parent:

badge = window.create("box", position="absolute", x=28, y=-4, width=16, height=16,
                      corner_radius=8, fill=(0xB3, 0x26, 0x1E, 0xFF))
icon_button.add_child(badge)

Insets (0.5.6.2). left, top, right and bottom (and inset, all four at once) place the node from each edge of its parent; x and y are left and top. Set both edges of an axis and leave that size auto, and the node is stretched between them: a strip 8 pixels in from each side, or a scrim that fills its parent:

bar = window.create("box", position="absolute", left=8, right=8, bottom=8, height=40)
scrim = window.create("box", position="absolute", inset=0, fill=(0, 0, 0, 0x80))

The insets are measured from the parent's edge, not its padding, and they follow the parent when it is resized. A margin shrinks the stretched node. A size that is not auto wins over the second inset (left=8, right=8, width="100%" is the parent's whole width). On a node in the flow the insets offset it from where it was laid out, as in CSS (right=8 moves it 8 pixels left).

Content that floats over the whole window — menus, dialogs, tooltips — goes in a layer instead, which also handles placement, input, and focus.

Anchored layout

Placing a node against another node (0.5.6.2). To put a badge on the corner of a distant button, a label beside a field, or a marker on a chart point, give the node an anchor:

badge = window.create("box", width=16, height=16, corner_radius=8,
                      anchor=button, placement="top_end", offset=(-2, 2))
header.add_child(badge)   # any parent: the button need not be near it

After each layout the tree puts the node at placement of the anchor's box, in the same pass, so it follows the anchor with no lag, through scrolling, scaling and other transforms above either of them. The node stays an ordinary child of its own parent, unlike a layer: it is clipped by clip_children, ordered by z_index with its siblings, is in the parent's Tab order, and its events bubble to its parent.

placement is "below" (the default), "above", "start" or "end" (outside the anchor, aligned to its start edge), or "top_start", "top_end", "bottom_start", "bottom_end" or "center": the node's centre on that point of the anchor. offset=(dx, dy) moves the point the node attaches to, in the anchor's own coordinates (+x toward the end, +y down; a scaled anchor scales it). anchor=None puts the node back in the flow.

Rules: setting an anchor makes the node absolute, and the tree writes its insets after every layout, so x, right and the like on it have no lasting effect. Unlike a layer it is never flipped or shifted to fit. A node cannot be anchored to itself or to a node inside it (a ValueError); two nodes anchored to each other have no answer, so they are put back in the flow and a warning is logged; a chain (A to B to C) works. If the anchor is removed the node stays where it was. The node's own translate_*, scale and rotation_deg apply on top of the place it is put. The anchor's box is the bounds of its laid-out box through its transform. placement and offset need an anchor, in the same call or before.

Reading the layout

The layout event (0.5.6.2). To be told when a node's size changes, rather than polling each frame, listen for layout. It fires when the node is first laid out and whenever its laid-out size changes, whatever the cause: a set, the parent being resized, a calc size resolving, new text in a "fit-content" label. event.width and event.height are the new size; event.old_value is None the first time and the old (width, height) after. Only a node with a listener is watched, so it costs nothing elsewhere. It doesn't bubble, and arrives once a frame, after that frame's layout.

panel.on("layout", lambda e: chart.set(width=e.width - 32, height=e.height - 32))

layout_x, layout_y, layout_width, and layout_height read a node's computed box in window coordinates. Reading one runs any pending layout first, so it always matches the tree as it is now:

window.root.add_child(panel)
print(panel.get("layout_x"), panel.get("layout_width"))

A detached subtree is laid out on its own at its content size, so you can measure a node before attaching it. layout_x and layout_y include the node's transforms (translate_x, scale, ...) and its ancestors', so they say where it's drawn; layout_width and layout_height are its size before scaling. A transform never moves any other node.

Changing the tree

Method Does
add_child(child) / insert_child(index, child) Attach child, moving it if it's attached elsewhere
children() / parent() Walk the tree
remove() Detach the node; it stays alive while you hold a handle to it
destroy() Free the node and its subtree now

Moving a node keeps everything about it — its handle, listeners, focus, and any running animation — so a list reorders by moving rows rather than rebuilding them:

for index, key in enumerate(new_order):
    list_box.insert_child(index, rows[key])

Switching screens is old.remove() then window.root.add_child(new). The removed screen keeps its scroll offsets, text, and selection, so attaching it again brings it back as it was. A detached node you no longer hold is freed automatically; destroy() frees one now. See Lifetime.

Visibility and stacking

visible=False hides a node and its subtree: it isn't painted, takes no layout space, can't be hit or focused, and leaves the accessibility tree. Siblings paint in order, later on top; z_index changes that without reordering. clip_children=True clips a node's children to its rounded box.

Scrolling

A "scroll_view" clips its content and scrolls it along its orientation, "vertical" or "horizontal". Give it one content child that holds the rest:

view = window.create("scroll_view", width=300, height=200)
content = window.create("box", flex_direction="vertical", width=300, gap=4)
for name in names:
    content.add_child(window.create("text", text=name, width=280, height=20))
view.add_child(content)

Snapping (0.5.6)

For a carousel, a tab strip or a picker, scroll_snap settles the view on an item:

strip = window.create("scroll_view", orientation="horizontal", width=300, height=80,
                      scroll_snap="start")        # or "center" / "end" / "none"
row = window.create("box", flex_direction="horizontal")
strip.add_child(row)
for card in cards:
    row.add_child(card)                            # the items: children of the content box
cards[0].set(snap_align="center")                  # this one lines up differently
  • The snap points are the content box's children, each lined up with the view's start, middle or end (its own snap_align if it has one), clamped to the scrollable range.
  • The view settles 120 ms after the last input (wheel, touch pan, key, scrollbar drag) and once a touch fling has stopped, animating about 200 ms to the nearest point. More input during the wait puts it off; a new input during the settle takes it over.
  • Your own set(scroll_offset=) and animate("scroll_offset", ...) are not snapped: they go where you say. scroll_snap="none" turns snapping off.
  • It always settles ("mandatory"); a "proximity" mode and snapping a virtual_list are not provided. A virtual list does have a scroll_offset (settable, readable, animatable) to settle on a row yourself.
  • Over a horizontal view a plain vertical wheel turn still scrolls the page around it (0.4.4); Shift+wheel, or a sideways wheel or trackpad, scrolls the strip.

The mouse wheel scrolls it — the nearest scroll view under the pointer that can move the wheel's way. A view the wheel can't move passes it on to the one outside it (scroll chaining, 0.4.4): a plain wheel over a horizontal carousel scrolls the page around it, and so does a wheel over an inner list that's already at its end, or has nothing to scroll. Shift+wheel scrolls a horizontal view (0.4.3). So does setting scroll_offset, which also animates — how a carousel eases to a snap point:

view.animate("scroll_offset", 400, 300, easing=(0.2, 0.0, 0.0, 1.0))

An offset past the end is the end: set and animate clamp it to how far the content reaches past the view (0.4.3), so it reads back right at once and an animation eases all the way there.

Keyboard scrolling

Keys scroll the nearest scroll view around the focused node (0.4.2): the arrow keys by 40 pixels, Page Up and Page Down by a viewport, Home and End to either end. Up, Down, Page Up, and Page Down move a vertical view, Left and Right a horizontal one. A view that can't move a key's way — across its axis, at that end, or with nothing to scroll — passes it to the next one out (0.4.4), so Page Down at the end of an inner list moves the page. Keys the focused node uses itself don't scroll: a node with a key_down listener between the focused node and the view keeps every key, and a text input keeps all but Page Up and Page Down. With nothing focused, keys scroll nothing. A key pressed with Ctrl, Alt, or Meta held is a shortcut, not a scroll (0.4.3) — Ctrl+Page Down or Alt+Left stays yours — while Shift still scrolls. Keyboard scrolls jump rather than ease.

Keys, scroll_into_view, and the scroll event below apply to "scroll_view" nodes; a "virtual_list" scrolls by wheel only.

Revealing a node

node.scroll_into_view() scrolls every scroll view around node, innermost first, by the least that shows it; a node longer than a view is aligned to its start. Focus does the same, so Tab never lands on a node out of sight, and so does an assistive technology's scroll_into_view request.

rows[40].scroll_into_view()

The scroll event

A scroll view fires scroll whenever its offset changes, whatever changed it: the wheel, a key, scroll_into_view, focus, set, or an animation, once a frame. event.old_value and event.new_value are the offsets before and after. It doesn't bubble.

view.on("scroll", lambda e: status.set(text=f"{e.new_value:.0f} px down"))

See examples/scroll_keys.py.

Sticky headers

node.set(sticky=inset) (0.5.4) makes a node hold the start edge of the scroll view around it, as CSS's position: sticky does: it scrolls with its content until it reaches inset pixels from the top (the left, in a horizontal view), then stays there while its own parent scrolls past, and is pushed out by the end of that parent. Put a header in each section and give it sticky=0:

for title, items in sections:
    section = window.create("box", flex_direction="vertical")
    header = window.create("box", height=32, fill=HEADER, z_index=1, sticky=0)
    section.add_child(header)
    ...
    content.add_child(section)

The node moves at paint time only: its layout position, and so its siblings', never change, and hit testing and accessibility bounds follow where it is drawn. As in CSS, content that comes later in the section paints over a stuck header unless the header is above it, so give it a z_index. Details: it sticks within the nearest scroll view or virtual list (a direct child of the view is the content itself and can't stick); transforms between it and the view are not accounted for, and a sticky node inside another sticky node ignores the outer one's movement. sticky=None makes it ordinary again. See examples/sticky_headers.py.

Virtual lists

A "virtual_list" has item_count rows but builds only the ones its viewport shows. materialize(index) returns a node for a row as it scrolls into view; rows scrolled away are released.

def row(index):
    return window.create("text", text=items[index], height=32)

rows = window.create("virtual_list", item_count=len(items), item_extent=32,
                     materialize=row, width=320, height=400)

Every row is item_extent tall, or give size_hint(index) for rows of different heights. A materialize or size_hint that raises is logged, and its row stays empty. Changing item_count (or either callback) rebuilds the visible rows. See examples/scrollable_list.py and examples/variable_height_list.py.