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_alignif 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=)andanimate("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_listare not provided. A virtual list does have ascroll_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.