How Tessella Works

This page explains the ideas behind Tessella’s design: how a screen is put together out of widgets, the kinds of widgets you’ll encounter, and how state survives across rebuilds of the interface. It’s a good companion to the more hands-on Tutorial and the per-widget Widgets guide.

Basics

Widget tree

In Tessella, interfaces are built out of widgets: the fundamental building blocks of the screen. Each widget describes one isolated piece of the interface, whether that’s something visual (text, an image, a button) or something structural (alignment, spacing, layout).

Widgets nest inside one another, forming a tree. Every widget can have one or more children, all the way up to the root, which is the entry point of the interface:

Container(
    child=Center(
        child=Text("Hello, world!")
    )
)

Here, Container is the root and has Center as its child, which in turn centers the Text widget inside itself. Every widget inherits from the same base class, so any widget can act as the root: there’s no special type reserved for that role. That makes it possible to test and compose interfaces starting at any level of the hierarchy.

Types of widgets

Widgets fall into two categories: primitive and composite. Primitive widgets carry their own internal logic and do one specific job, such as drawing text, applying margins, or capturing gestures. Composite widgets are built by combining other widgets (usually primitives) into a higher-level component: the Button widget, for example, is assembled from a GestureDetector, a Container and a Text.

This composition-based approach lets complex components be built out of small, reusable pieces instead of large, monolithic widgets, keeping code easier to read and maintain.

Widgets can also be classified by purpose:

  • Renderable: widgets visible to the user, such as text, buttons and switches.

  • Functional: invisible widgets with specific behavior, such as the gesture detector.

  • Structural: widgets used to position other elements, such as columns, rows and stacks.

State control in widgets

Reusable components are usually built as a new type based on StatelessWidget, letting you assemble a composite component tailored to your needs.

Consider a screen made of a container holding a row with three columns, each column pairing a line of text with a switch underneath it. All three labels share the same style, defined once and reused throughout the examples on this page:

text_style = TextStyle(font_color="#eeeeee", font_size=20)
Container(
    child=Row(
       main_axis_alignment=MainAxisAlignment.SPACE_BETWEEN,
        children=[
            Column(
                shrink_wrap=True,
                main_axis_alignment=MainAxisAlignment.CENTER,
                children=[
                    Text(
                        text="Option 1",
                        style=text_style
                    ),
                    Switch(WidgetKey())
                ]
            ),
            Column(
                shrink_wrap=True,
                main_axis_alignment=MainAxisAlignment.CENTER,
                children=[
                    Text(
                        text="Option 2",
                        style=text_style
                    ),
                    Switch(WidgetKey())
                ]
            ),
            Column(
                shrink_wrap=True,
                main_axis_alignment=MainAxisAlignment.CENTER,
                children=[
                    Text(
                        text="Option 3",
                        style=text_style
                    ),
                    Switch(WidgetKey())
                ]
            )
        ]
    )
)

The three columns are nearly identical: only the label text differs. Tessella lets you avoid this repetition by turning the repeated structure into a new, custom widget:

class LabeledSwitch(StatelessWidget):
    def __init__(self, label: str):
        super().__init__()
        self.label: str = label

    def build(self) -> Widget:
        return Column(
            shrink_wrap=True,
            main_axis_alignment=MainAxisAlignment.SPACE_BETWEEN,
            children=[
                Text(
                    text=self.label,
                    style=text_style
                ),
                Switch(WidgetKey())
            ]
        )

LabeledSwitch takes a string argument, and its build method reproduces the structure that was repeated above, using that argument instead of a fixed value. The repeated block can now be replaced with the new widget:

Container(
    child=Row(
        main_axis_alignment=MainAxisAlignment.SPACE_AROUND,
        children=[
            LabeledSwitch("Option 1"),
            LabeledSwitch("Option 2"),
            LabeledSwitch("Option 3")
        ]
    )
)

If LabeledSwitch doesn’t need to be a real widget type, a plain function works just as well and skips the class boilerplate:

def labeled_switch(label: str) -> Widget:
    return Column(
        shrink_wrap=True,
        main_axis_alignment=MainAxisAlignment.SPACE_BETWEEN,
        children=[
            Text(
                text=label,
                style=text_style
            ),
            Switch(WidgetKey())
        ]
    )

Called the same way, just as labeled_switch("Option 1") instead of LabeledSwitch("Option 1"). Reach for a function when you’re only avoiding repeated code and the result doesn’t need to carry its own state, be checked with isinstance, or otherwise behave as a distinct tree node. Reach for StatelessWidget once it does. Custom widgets can be built out of other custom widgets, recursively, without limit.

Sometimes an interface has mutable state of its own, such as the value of a slider or whether a switch is on. Rebuilding the widget tree normally discards the affected widgets and creates fresh ones in their place, which would wipe that state out. To keep it around, use stateful widgets: inherit from StatefulWidget and WidgetState. This example adapts the interface above to track each switch’s state correctly:

class LabeledSwitchState(WidgetState):
    def __init__(self):
        self.switch_key: WidgetKey = WidgetKey()

class LabeledSwitch(StatefulWidget[LabeledSwitchState]):
    def __init__(self, key: WidgetKey, label: str):
        super().__init__(key)
        self.label: str = label

    def create_state(self) -> LabeledSwitchState:
        return LabeledSwitchState()

    def build(self) -> Widget:
        return Column(
            shrink_wrap=True,
            main_axis_alignment=MainAxisAlignment.CENTER,
            children=[
                Text(
                    text=self.label,
                    style=TextStyle(
                        font_size=24,
                        font_color="#eeeeee",
                        anti_aliasing=True,
                    )
                ),
                Switch(self.state.switch_key)
            ]
        )

Parts of the widget tree can be discarded during interface updates, clearing out any information stored directly on the widgets once they’re rebuilt. Stateful widgets work around this by keeping their state in a separate object (the “state”), which survives rebuilds and is retrieved through a widget key. Widget keys are instances of WidgetKey, and their job is to uniquely identify a widget.

LabeledSwitch takes a key to represent itself and implements create_state to create a new state object on initialization. That state object initializes and stores its own key for the Switch widget (accessed through self.state), so the switch’s state is preserved too.

StatefulWidget takes a generic type T. It’s strongly recommended (though not required) that this type inherit from WidgetState.

Widget keys must be created outside the build method, not inside it, so they survive changes to the widget tree. This example shows the correct and incorrect way to do it:

class LabeledSwitch(StatefulWidget[LabeledSwitchState]):
    def __init__(self, key: WidgetKey, label: str):
        super().__init__(key)
        self.label: str = label

    def build(self) -> Widget:
        return Column(
            shrink_wrap=True,
            main_axis_alignment=MainAxisAlignment.CENTER,
            children=[
                Text(
                    text=self.label,
                    style=TextStyle(
                        font_size=24,
                        font_color="#eeeeee",
                        anti_aliasing=True,
                    )
                ),
                # CORRECT: reference an external key
                Switch(self.state.switch_key)
            ]
        )
class LabeledSwitch(StatefulWidget[LabeledSwitchState]):
    def __init__(self, key: WidgetKey, label: str):
        super().__init__(key)
        self.label: str = label

    def build(self) -> Widget:
        return Column(
            shrink_wrap=True,
            main_axis_alignment=MainAxisAlignment.CENTER,
            children=[
                Text(
                    text=self.label,
                    style=TextStyle(
                        font_size=24,
                        font_color="#eeeeee",
                        anti_aliasing=True,
                    )
                ),
                # WRONG: instantiate a new key
                Switch(WidgetKey())
            ]
        )

build can be called many times over the life of the program. Instantiating a key directly inside it means a new key is created on every rebuild, which invalidates the widget’s previous state. Keys need to live outside build so the widget can be correctly identified, and its state preserved, across updates.

Layout system

Tessella’s layout system is declarative and tree-structured. Layout is calculated recursively: each widget receives size constraints from its parent, sizes itself within those constraints, and passes new constraints down to its own children. Some widgets size themselves from the bottom up instead; a Column, for example, sizes itself based on its children’s combined size, so its layout can only resolve once theirs does. None of this needs any intervention from you.

Layout happens in three phases:

  1. Constraint propagation: widgets adjust the size limits passed down based on available space and their parent’s behavior.

  2. Size determination: each widget works out its own dimensions from the constraints it received.

  3. Positioning: each widget is placed at its correct location.

This recursive system is what lets complex layouts be built out of simple, composed widgets, as shown above.

Layout constraints

Layout constraints are a minimum and maximum width and height, bounding the space a widget may expand into, plus a top-left coordinate for where the widget should be positioned. In Tessella, these are encapsulated in an immutable WidgetConstraints structure, handed to each widget by its parent during the layout pass.

A widget’s calculate_size is expected to size itself using the constraints it received, the same way every built-in widget does. Honoring min_width, max_width and their height counterparts isn’t enforced by the base Widget class though: it’s each widget’s own responsibility, so a custom widget that ignores its constraints will simply overflow instead of getting clamped. Widgets can be built to adapt flexibly across a range of sizes, or to behave rigidly under strict limits (a fixed width, for instance).

Constraint propagation

Constraint propagation passes information about available space down to children, top-down, starting at the root and moving toward the leaves.

Each widget transforms the constraints it receives and applies new ones to its own children, according to its own layout semantics. A Padding widget, for example, shrinks the available limits by its padding values, while a Row splits the available width among its children.

The layout phase, summarized:

  1. The parent calls apply_layout_constraints on each child.

  2. Each child works out its size from the constraints it received.

  3. The parent positions each child within its own available space.

This starts when calculate_layout is called on a root widget, passed a rectangle describing the space available for the interface. The resulting absolute positions are what rendering draws with. As with input handling, this isn’t tied to a single root: an app driving multiple independent widget trees calls calculate_layout separately on each one, including on resize.

Input handling

Input handling uses a recursive event propagation model, much like layout. It takes events fired by Pygame (clicks, key presses, mouse movement) and dispatches them to the widgets in the tree.

Each input event is an immutable pygame.Event carrying context: its type, position, the button pressed, or the key involved. New events should be forwarded to the root of a widget tree, which propagates them internally.

Nothing requires a single root, either. An app can maintain multiple independent widget trees at once, say gui1 for an in-game HUD and gui2 for a pause menu, each with its own root. Calling process_event separately on each one propagates the event through that tree alone; the trees don’t know about each other, so it’s up to you to decide which roots receive a given event (and in what order, if that matters for your app).

Widgets can respond selectively to the events they care about, and can also stop an event from being acted on more than once. This matters most in overlays and context menus made up of several interactive components.

Event propagation

Propagation happens recursively, root to leaves. At each step, the system checks whether the event is still unconsumed, based on whether some other widget has already handled it.

Each widget receives the event with its positional context and decides whether to act on it. If a widget captures an event and marks it consumed, the event still passes down to the remaining widgets, but won’t be handled again.

Widgets rendered through Overlay are a special case: they get first look at every event, ahead of the rest of the tree, regardless of where they actually sit structurally. This is what lets Dropdown react to a click landing anywhere in the interface by closing its floating list, even though the click wasn’t aimed at it. Closing the dropdown this way doesn’t consume the event, so whatever the click actually landed on still handles it normally afterward.

This starts when process_event is called on a root widget with a pygame.Event, the same way gui.process_event(event) is called from the game loop in Quickstart. Each widget passes the event, and the updated consumed state, on to its children. If your app drives more than one root, call process_event on each of them; every call starts its own, independent propagation pass.

process_event returns a bool: whether the event ended up consumed by the time propagation finished. That return value is what lets you chain multiple roots together when order matters, for instance letting a pause menu’s root see an event first and only forwarding it to the gameplay root underneath if it comes back unconsumed:

consumed = pause_menu.process_event(event)
gui1.process_event(event, consumed=consumed)

Updating the UI

The interface updates continuously, in sync with the application’s main loop, generally once per frame. During this cycle, update should be called recursively starting at a root, letting every widget refresh its own internal state. This is what makes time-based logic, like animations or timers possible. TextField already relies on it to blink its insertion cursor while focused, and any custom widget can use it the same way.

Call each root widget’s update at the start of every main loop iteration, right after processing input and before rendering, so state is always current by the time the frame renders. As with process_event and calculate_layout, this is per root: an app driving multiple independent widget trees updates each one separately.

Subject-observer pattern

To handle communication between widgets in different parts of the tree, and with data from outside the tree entirely, Tessella implements a variation of the subject-observer pattern.

A subject object (or notifier) can be watched by multiple widgets, called listeners. Whenever the subject changes meaningfully, it notifies every registered listener, which reacts by rebuilding its own visuals.

The Listener widget lets part of the tree watch a shared value:

click_counter: ValueNotifier[int] = ValueNotifier[int](1)

gui: Widget = Center(
    Listener(
        observable=click_counter,
        builder=lambda clicks: Center(
            Text(f"Clicks: {clicks.value}")
        )
    )
)

Here, click_counter is a ValueNotifier[int]: an observable integer that notifies its listeners on every change.

Listener watches click_counter and rebuilds its content whenever the value changes. The builder function defines the new visual subtree to show for the counter’s current value, which is what makes the displayed count update dynamically.

Using Listener decouples logic from presentation: the counter can be updated from anywhere, and the interface reacts on its own, with no imperative instructions or direct references between widgets required.

Partial updates

Tessella’s declarative model lets the interface rebuild often without hurting performance, thanks to a rendering system built around partial updates. When a widget is marked for rebuilding (for instance, when a listener notices its observed value changed), only the affected subtree gets recalculated and redrawn. The rest of the tree stays untouched.

Consider the simplified structure of a Button widget:

GestureDetector(
    child=Container(
        width=self.width,
        height=self.height,
        child=Center(
            Text(
                text=self.text,
            )
        )
    )
)

The button is built out of a Container with an explicit width and height, so it keeps a constant size regardless of the layout constraints imposed on it from outside. Updates happening elsewhere in the interface don’t affect the button’s subtree, so there’s no need to recalculate its layout.

The same applies to updates triggered from inside the button itself: when a state change causes it to rebuild (through invalidate_child, the same mechanism Listener uses), only that widget’s own subtree is rebuilt and re-measured. Since the container has a fixed size, its position and dimensions stay unchanged no matter what happens inside it, keeping the rest of the interface’s layout intact. This is what lets Tessella stay fast even in highly dynamic interfaces.

Styling

Tessella’s styling system is declarative and specific to each widget type, giving fine-grained control over how visual elements look. Every stylable widget accepts an associated style object bundling together the visual attributes that matter to it: colors, margins, borders.

Separating style from structure promotes reuse, clarity and visual consistency, and lets different instances of the same widget share a single style object:

regular_text = TextStyle(
    font_color="#eeeeee",
    font_size=32,
    anti_aliasing=True
)

my_text = Text("Hello!", style=regular_text)
my_other_text = Text("World!", style=regular_text)

Both Text widgets above share the same style instance. This cuts down on repetition and guarantees visual uniformity across parts of the interface that serve a similar purpose. Bundling visual parameters into a reusable object means a change, like adjusting a font color or size, can be made in one place and apply automatically everywhere that style is used.

This centralization matters most in larger applications, where a consistent look and feel is important. Tessella takes it further with the Palette class, which holds the default colors every *Style class draws from: surfaces, borders, text and accent colors all come from it. Re-theming the whole widget set is a matter of editing Palette rather than tracking down every individual style class.

Full reference: Palette.

Since structure and style are entirely separate, the same widget tree can look like a completely different interface just by swapping the style objects fed into it. The three screenshots below are all the same inventory panel: same grid, same slots, same items, same details panel, same Row/Column/Container structure and spacing, with only colors, border radii and fonts changed between them:

The inventory panel styled as a dim, torch-lit dungeon ledger, with a blackletter title and warm brown/gold colors.

Dungeon Ledger

The same inventory panel styled as a light, pastel cottage-shop ledger, with a cursive script title and cream/rose colors.

Cottage Parlor

The same inventory panel styled as a neon sci-fi terminal, with a monospace title and cyan/magenta colors on black.

Terminal Cache

Each theme also picks a display font to match its mood: a blackletter face, a calligraphic script, and a monospace/typewriter face respectively, passed in through TextStyle(font_path=...) for just the title and item name, while the body copy keeps Tessella’s bundled default font for readability. Source for all three themes lives in docs/styling_showcase.py.

Debug mode

For inspection and troubleshooting, every widget supports a visual debug mode that draws an outline (or, for a few widgets, a translucent fill or tint) marking exactly the space it occupies, instead of its normal contents. It’s driven by the debug_mode argument on render:

gui.render(display, debug_mode=True)

Each widget type uses its own distinct, consistent color, so you can tell at a glance which widget an outline belongs to without cross-referencing code: Row/Column outline in green, Container in pink, Align/Text in blue, and so on through the rest of the widget set, including structural wrapper widgets (StatelessWidget, StatefulWidget, Stack, SizedBox, Positioned, Listener) that have no visuals of their own outside debug mode.

Because composite widgets (like Button or Checkbox) are built out of other widgets, their debug outline is layered underneath the outlines of whatever they’re built from. A Button, for instance, shows its own StatefulWidget outline plus the GestureDetector, Container, Center and Text outlines nested inside it. This makes the debug view a literal picture of the widget tree rather than just the final visual result, useful for tracking down layout issues like unexpected padding, misalignment, or a widget silently expanding to fill more space than intended.

One thing worth knowing: debug mode doesn’t cache its output the way normal rendering does. Outside debug mode, a widget draws its surface once and reuses it every frame until something actually invalidates it (see Partial updates above); the debug outline is redrawn from scratch on every single frame instead, whether or not anything changed. Debug mode is a troubleshooting tool, not something meant to ship, so staying simple and always current mattered more than being fast. It does mean debug mode can visibly lag on large or complex trees, though. A temporary cache that fills in while debug mode is on and gets garbage-collected the moment it’s switched off would be a welcome contribution.