Skip to content

Getting Started

Build one small app, a label and a button that counts, twice: declaratively (a YAML view and a Python ViewModel) and imperatively (Python calls that create nodes). Each step shows both, and every program here is a file in examples/getting_started/ that Tesserae's tests run.

Install

You need Python 3.12 or newer and a desktop session with a Vulkan graphics driver (any current Linux, macOS or Windows install has one; see Installation if a window won't open).

mkdir counter && cd counter
python3 -m venv .venv
source .venv/bin/activate     # fish: source .venv/bin/activate.fish
pip install tesserae-ui

tesserae-ui is the name on PyPI; you import tesserae. Everything below goes in the counter folder, with the environment active.

1. A window

An App owns the window. Make one 360 by 160 with a dark background (so the white text we add next reads on any desktop theme) and open it.

Counter_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 360, height: 160, gap: 12, padding: 16, background: "#1C1B1F"}
app.py
from pathlib import Path

from tesserae import App

app = App(width=360, height=160, title="Counter")
view = app.build_view(Path(__file__).parent / "Counter_View.yaml")
app.register("Counter", view, None)  # a screen with no ViewModel yet
app.show("Counter")
app.run()
app.py
from tesserae import App

app = App(width=360, height=160, title="Counter")
window = app.window
window.root.set(flex_direction="vertical", gap=12, padding=16, fill=(0x1C, 0x1B, 0x1F, 0xFF))

app.run()

Declaratively the window shows a screen: app.build_view reads Counter_View.yaml and app.register names it (None is its ViewModel, which it doesn't need yet). Imperatively there is no file: window.root is the box every node goes in, and one call sets its layout and background.

python app.py

An empty window titled "Counter" opens.

2. A node

A node is one thing on screen. Add a label that says Count: 0.

Counter_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 360, height: 160, gap: 12, padding: 16, background: "#1C1B1F"}
children:
  - id: label
    kind: Text
    text: {content: "Count: 0", font_family: Roboto, font_size: 20}
    style: {width: 160, height: 28, foreground: "#FFFFFF"}
app.py
from pathlib import Path

from tesserae import App

app = App(width=360, height=160, title="Counter")
view = app.build_view(Path(__file__).parent / "Counter_View.yaml")
app.register("Counter", view, None)  # a screen with no ViewModel yet
app.show("Counter")
app.run()
app.py
from tesserae import App

app = App(width=360, height=160, title="Counter")
window = app.window
window.root.set(flex_direction="vertical", gap=12, padding=16, fill=(0x1C, 0x1B, 0x1F, 0xFF))

label = window.create("text", text="Count: 0", font_size=20, width=160, height=28, fill=(255, 255, 255, 255))
window.root.add_child(label)

app.run()

In YAML a node is an entry in children:: kind: Text makes a label and text: holds what it says. In code, window.create("text", ...) makes the node and window.root.add_child(...) puts it in the window. The text's colour is foreground in YAML and fill= in code.

If Python prints an error

A mistake in a view file ends in a traceback; read the last line. It names the file, the widget and the problem, and for a misspelt word says what you probably meant:

ValueError: .../Counter_View.yaml: widget "label": unknown style field(s) ['foregorund'] -- did you mean 'foreground'?

3. A second node

Add a button: a purple, rounded box with a centred text node in it.

Counter_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 360, height: 160, gap: 12, padding: 16, background: "#1C1B1F"}
children:
  - id: label
    kind: Text
    text: {content: "Count: 0", font_family: Roboto, font_size: 20}
    style: {width: 160, height: 28, foreground: "#FFFFFF"}
  - id: button
    kind: Rect
    style: {width: 96, height: 40, background: "#6750A4", corner_radius: 20, align_content: left}
    children:
      - id: button_label
        kind: Text
        text: {content: Count, font_family: Roboto, font_size: 16, text_align: center}
        style: {width: 96, height: 20, foreground: "#FFFFFF"}
app.py
from pathlib import Path

from tesserae import App

app = App(width=360, height=160, title="Counter")
view = app.build_view(Path(__file__).parent / "Counter_View.yaml")
app.register("Counter", view, None)  # a screen with no ViewModel yet
app.show("Counter")
app.run()
app.py
from tesserae import App

app = App(width=360, height=160, title="Counter")
window = app.window
window.root.set(flex_direction="vertical", gap=12, padding=16, fill=(0x1C, 0x1B, 0x1F, 0xFF))

label = window.create("text", text="Count: 0", font_size=20, width=160, height=28, fill=(255, 255, 255, 255))
window.root.add_child(label)

button = window.create("box", width=96, height=40, corner_radius=20, fill=(0x67, 0x50, 0xA4, 0xFF),
                       align_items="center")
button.add_child(window.create("text", text="Count", font_size=16, width=96, height=20,
                               fill=(255, 255, 255, 255), text_align="center"))
window.root.add_child(button)

app.run()

A button is a Rect (a coloured box) with a Text in it. The two ways name things a little differently:

YAML view Python
kind: Text / kind: Rect window.create("text", ...) / window.create("box", ...)
style: {width: 96, ...} width=96
foreground, background ("#RRGGBB") fill=(r, g, b, a)
text: {content: "...", font_size: 20} text="...", font_size=20
children: node.add_child(other)

4. Make it count

Counter_ViewModel.py
from tesserae import Signal, ViewModel


class CounterViewModel(ViewModel):
    def __init__(self, view):
        self.count = 0
        self.label_text = Signal("Count: 0")
        super().__init__(view)  # last: it reads the Signals above

    def increment(self):
        self.count += 1
        self.label_text.set(f"Count: {self.count}")
Counter_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 360, height: 160, gap: 12, padding: 16, background: "#1C1B1F"}
children:
  - id: label
    kind: Text
    text: {content: "Count: 0", font_family: Roboto, font_size: 20}
    bindings: {text: "{{ label_text.get() }}"}
    style: {width: 160, height: 28, foreground: "#FFFFFF"}
  - id: button
    kind: Rect
    handlers: {on_click: increment}
    style: {width: 96, height: 40, background: "#6750A4", corner_radius: 20, align_content: left}
    children:
      - id: button_label
        kind: Text
        text: {content: Count, font_family: Roboto, font_size: 16, text_align: center}
        style: {width: 96, height: 20, foreground: "#FFFFFF"}
app.py
from pathlib import Path

from tesserae import App

from Counter_ViewModel import CounterViewModel

app = App(width=360, height=160, title="Counter")
app.load(Path(__file__).parent / "Counter_View.yaml", CounterViewModel)
app.show("Counter")
app.run()
app.py
from tesserae import App

app = App(width=360, height=160, title="Counter")
window = app.window
window.root.set(flex_direction="vertical", gap=12, padding=16, fill=(0x1C, 0x1B, 0x1F, 0xFF))

label = window.create("text", text="Count: 0", font_size=20, width=160, height=28, fill=(255, 255, 255, 255))
window.root.add_child(label)

button = window.create("box", width=96, height=40, corner_radius=20, fill=(0x67, 0x50, 0xA4, 0xFF),
                       align_items="center")
button.add_child(window.create("text", text="Count", font_size=16, width=96, height=20,
                               fill=(255, 255, 255, 255), text_align="center"))
window.root.add_child(button)

clicks = 0


def count():
    global clicks
    clicks += 1
    label.set(text=f"Count: {clicks}")


button.on("click", count)
button.set(role="button", label="Count", focusable=True, cursor="pointer")  # Tab, then Enter or Space

app.run()

Declaratively the state lives in a ViewModel, paired with the view by name (Counter_View.yaml and Counter_ViewModel.py). Its Signal holds the label's text; the view's bindings: keeps the label showing it, and handlers: calls increment on a click. app.load pairs the two files.

Imperatively the state is a variable and you are the view: button.on("click", count) runs count, which sets the label with label.set(text=...).

Run it. Click the button, or press Tab and then Enter or Space, and the count goes up. The two versions are the same app.

Hot reload

Change app.run() to app.run(hot_reload=True) and edit Counter_View.yaml while the app is open: the window updates at once and the count stays. See Hot Reload.

Which to use

Use declarative for the screens you design: layout, styling, themes and bindings live in the view file, and the ViewModel holds only your logic. Use imperative for what is made while the app runs; the Widgets in Python guide covers it. The two mix freely, because a declarative screen is made of the same nodes you create in code: view.node("button") returns any node the YAML declared.

Next