Skip to content

Tutorial: A Tasks App

Getting Started builds a counter. This tutorial builds a small Tasks app in six steps, each a working program that adds one idea to the last, to show you the options you have: inline styling and separate files, built-in components and your own, one view or several, an ordinary window or your own frame. Every step is a folder in examples/tutorial/ that Tesserae's tests run; the page shows the files a step adds or changes.

The files here sit side by side and are named by their paths. The same app as a project puts them in the folders tesserae new makes and finds them by name.

You need a working install (Getting Started). Make a folder for the app, and for each step put its files in it (or run a step straight from a checkout: python examples/tutorial/step3/app.py).

1. A screen

One screen: a title, a count, and a button. A view is the YAML; its ViewModel is the Python that holds the state. They are paired by name, Tasks_View.yaml and Tasks_ViewModel.py.

Tasks_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 100%, height: 100%, gap: 12, padding: 24, background: surface}
children:
  - id: title
    kind: Text
    text: {content: Tasks, typography_role: headline_medium}
    style: {foreground: on_surface}
  - id: summary
    kind: Text
    text: {content: 0 tasks, typography_role: body_large}
    style: {foreground: on_surface_variant}
    bindings: {text: "{{ summary.get() }}"}
  - id: add
    component: ButtonFilled
    with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
    handlers: {on_click: add_task}
Tasks_ViewModel.py
from tesserae import Computed, Signal, ViewModel


class TasksViewModel(ViewModel):
    def __init__(self, view):
        self.tasks = Signal([])
        self.summary = Computed(lambda: f"{len(self.tasks.get())} tasks")
        super().__init__(view)  # last: it reads the Signals the view refers to

    def add_task(self):
        self.tasks.update(lambda tasks: [*tasks, f"Task {len(tasks) + 1}"])
app.py
from pathlib import Path

from tesserae import App

from Tasks_ViewModel import TasksViewModel

HERE = Path(__file__).parent

app = App(width=420, height=520, title="Tasks", theme_seed=(0x0B, 0x6B, 0x58, 0xFF))
app.load(HERE / "Tasks_View.yaml", TasksViewModel)
app.show("Tasks")
app.run()
  • Inline styling. Each node has its own style:, with colours named for theme roles (surface, on_surface), not hex codes, so they follow light and dark.
  • A built-in component. component: ButtonFilled puts MD3's filled button in place; with: fills its parameters. Every component has a page.
  • State. tasks is a Signal; summary is a Computed that follows it. The view's bindings: keeps the text showing it, and handlers: calls add_task on a click.
  • The app. app.load pairs the two files by name; theme_seed makes the colours.
python app.py

2. A list of components

A task is more than a line of text: it has a checkbox and a remove button, and state of its own. That makes it a component: another view and ViewModel pair, embedded once for each task. A Repeater keeps one alive for each entry of a list Signal, adding and removing them as the list changes.

TaskItem_View.yaml
id: root
kind: Container
style: {flex_direction: horizontal, align_content: left, gap: 8, width: 100%}
children:
  - id: done
    kind: Checkbox
    style: {width: 40, height: 40, background: surface}
    bindings: {checked: "{{ done.get() }}"}
    two_way: checked
  - id: label
    kind: Text
    text: {content: Task, typography_role: body_large}
    style: {flex: expand_horizontal, foreground: on_surface}
    bindings: {text: "{{ text.get() }}"}
  - id: remove
    component: IconButtonStandard
    with: {icon: close, size: 40, corner_radius: 20}
    handlers: {on_click: remove_self}
TaskItem_ViewModel.py
from tesserae import Signal, ViewModel


class TaskItemViewModel(ViewModel):
    def __init__(self, view, task_id, tasks):
        self.task_id = task_id
        self.tasks = tasks
        self.text = Signal(f"Task {task_id}")
        self.done = Signal(False)
        super().__init__(view)

    def remove_self(self):
        # The row removes itself by changing the list; the Repeater tears it down.
        self.tasks.update(lambda ids: [i for i in ids if i != self.task_id])
Tasks_View.yaml
id: root
kind: Container
style: {flex_direction: vertical, width: 100%, height: 100%, gap: 12, padding: 24, background: surface}
children:
  - id: title
    kind: Text
    text: {content: Tasks, typography_role: headline_medium}
    style: {foreground: on_surface}
  - id: summary
    kind: Text
    text: {content: 0 tasks, typography_role: body_large}
    style: {foreground: on_surface_variant}
    bindings: {text: "{{ summary.get() }}"}
  - id: add
    component: ButtonFilled
    with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
    handlers: {on_click: add_task}
  - id: list
    kind: Container
    style: {flex_direction: vertical, gap: 4, width: 100%}
Tasks_ViewModel.py
from pathlib import Path

from tesserae import Computed, Repeater, Signal, ViewModel

from TaskItem_ViewModel import TaskItemViewModel

HERE = Path(__file__).parent


class TasksViewModel(ViewModel):
    def __init__(self, view):
        self.tasks = Signal([])  # the ids of the tasks: the one source of truth
        self._next_id = 1
        self.summary = Computed(lambda: f"{len(self.tasks.get())} tasks")
        # One TaskItem component, with a ViewModel of its own, for each id in `tasks`.
        self.rows = Repeater(view, self.tasks, HERE / "TaskItem_View.yaml", TaskItemViewModel,
                             view.node("list"), args=lambda task_id: (task_id, self.tasks))
        super().__init__(view)

    def add_task(self):
        self.tasks.update(lambda ids: [*ids, self._next_id])
        self._next_id += 1
  • tasks holds the ids and is the single source of truth. The Repeater makes a TaskItem for each id, and args hands its ViewModel the id and the list.
  • A row removes itself by changing the list; nobody tears the row down by hand.
  • two_way: checked writes the checkbox back to the row's done Signal.

See Components & Embedding and Repeater.

3. Styling in files

Inline style: is fine for one node. Three kinds of file take styling out of the views:

Brand_Theme.yaml
seed: "#0B6B58"
typography:
  headline_medium: {font_weight: 600}
Tasks_Stylesheet.yaml
styles:
  - classes: [page]
    style: {flex_direction: vertical, width: 100%, height: 100%, gap: 12, padding: 24, background: surface}
  - classes: [heading]
    style: {foreground: on_surface}
  - classes: [muted]
    style: {foreground: on_surface_variant}
  - classes: [rows]
    style: {flex_direction: vertical, gap: 4, width: 100%}
  - classes: [task]
    style: {flex: expand_horizontal, foreground: on_surface}
row_Style.yaml
id: row_style
style:
  flex_direction: horizontal
  align_content: left
  gap: 8
  width: 100%
ButtonFilled_Stylesheet.yaml
# Next to the views, this goes over Tesserae's own ButtonFilled stylesheet, field by field.
styles:
  - id: root
    style: {background: tertiary}
  - id: label
    style: {foreground: on_tertiary}
Tasks_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Tasks, typography_role: headline_medium}
  - id: summary
    kind: Text
    classes: [muted]
    text: {content: 0 tasks, typography_role: body_large}
    bindings: {text: "{{ summary.get() }}"}
  - id: add
    component: ButtonFilled
    with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
    handlers: {on_click: add_task}
  - id: list
    kind: Container
    classes: [rows]
TaskItem_View.yaml
id: root
kind: Container
style: row_Style.yaml
children:
  - id: done
    kind: Checkbox
    style: {width: 40, height: 40, background: surface}
    bindings: {checked: "{{ done.get() }}"}
    two_way: checked
  - id: label
    kind: Text
    classes: [task]
    text: {content: Task, typography_role: body_large}
    bindings: {text: "{{ text.get() }}"}
  - id: remove
    component: IconButtonStandard
    with: {icon: close, size: 40, corner_radius: 20}
    handlers: {on_click: remove_self}
app.py
from pathlib import Path

from tesserae import App

from Tasks_ViewModel import TasksViewModel

HERE = Path(__file__).parent

app = App(
    width=420,
    height=520,
    title="Tasks",
    custom_theme=HERE / "Brand_Theme.yaml",   # the colours: made from one seed
    stylesheet=HERE / "Tasks_Stylesheet.yaml",  # the look of every screen
)
app.load(HERE / "Tasks_View.yaml", TasksViewModel)
app.show("Tasks")
app.run()
  • A theme (Brand_Theme.yaml) is app-wide: one seed colour makes the whole palette, and typography: changes a type style. Pass it as App(custom_theme=).
  • A stylesheet (Tasks_Stylesheet.yaml) is rules. A node says classes: [page] and a rule classes: [page] styles it; rules can also match a kind or an id. Pass it as App(stylesheet=).
  • A style file (row_Style.yaml) is one node's style:, shared: style: row_Style.yaml.
  • A component's own stylesheet. Built-in components keep their look in a stylesheet. A ButtonFilled_Stylesheet.yaml next to the views goes over it, field by field: here, a tertiary button.

The checkbox keeps its inline style:: mix as you like. A node's own style: always wins, then the stylesheet, then the theme. See Themes.

4. A component of your own

Two numbers in the same shape: that is a component. A *_Component.yaml is a fragment: structure with {{ parameters }}, and its look in a stylesheet of the same name.

Stat_Component.yaml
# A number with a label under it. `value` is a binding expression, e.g. "{{ count.get() }}".
params: [label, value]
id: root
kind: Container
children:
  - id: value
    kind: Text
    text: {content: "0", typography_role: title_large}
    bindings: {text: "{{ value }}"}
  - id: label
    kind: Text
    text: {content: "{{ label }}", typography_role: label_medium}
Stat_Stylesheet.yaml
# The look of Stat: one rule for each part.
styles:
  - id: root
    style:
      flex_direction: vertical
      align_content: top
      width: 96
      padding: 12
      background: secondary_container
      corner_radius: medium
  - id: value
    style: {foreground: on_secondary_container}
  - id: label
    style: {foreground: on_secondary_container}
Tasks_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Tasks, typography_role: headline_medium}
  - id: stats
    kind: Container
    classes: [row]
    children:
      - id: open
        component: Stat
        with: {label: Open, value: "{{ open_text.get() }}"}
      - id: finished
        component: Stat
        with: {label: Done, value: "{{ done_text.get() }}"}
  - id: add
    component: ButtonFilled
    with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
    handlers: {on_click: add_task}
  - id: list
    kind: Container
    classes: [rows]
Tasks_ViewModel.py
from pathlib import Path

from tesserae import Computed, Repeater, Signal, ViewModel

from TaskItem_ViewModel import TaskItemViewModel

HERE = Path(__file__).parent


class TasksViewModel(ViewModel):
    def __init__(self, view):
        self.tasks = Signal([])  # the ids of the tasks: the one source of truth
        self._next_id = 1
        self.rows = Repeater(view, self.tasks, HERE / "TaskItem_View.yaml", TaskItemViewModel,
                             view.node("list"), args=lambda task_id: (task_id, self.tasks))
        self.done_text = Computed(lambda: str(self._done()))  # a binding to text needs a string
        self.open_text = Computed(lambda: str(len(self.tasks.get()) - self._done()))
        super().__init__(view)

    def _done(self):
        self.tasks.get()  # recount when tasks come or go, and when a row's checkbox changes
        return sum(1 for _key, _component, row in self.rows if row.done.get())

    def add_task(self):
        self.tasks.update(lambda ids: [*ids, self._next_id])
        self._next_id += 1
Tasks_Stylesheet.yaml
styles:
  - classes: [page]
    style: {flex_direction: vertical, width: 100%, height: 100%, gap: 12, padding: 24, background: surface}
  - classes: [heading]
    style: {foreground: on_surface}
  - classes: [muted]
    style: {foreground: on_surface_variant}
  - classes: [rows]
    style: {flex_direction: vertical, gap: 4, width: 100%}
  - classes: [task]
    style: {flex: expand_horizontal, foreground: on_surface}
  - classes: [row]
    style: {flex_direction: horizontal, gap: 12}
  • component: Stat finds Stat_Component.yaml next to the view. Its parameters are label and value.
  • value is a binding expression, so each stat keeps up with its Computed. (A binding to text needs a string, so the Computeds return strings.)
  • A fragment has no ViewModel and no state of its own, which is what separates it from the components of step 2. Use a fragment for a repeated look, a view and ViewModel pair for repeated behaviour.

See Component Fragments.

5. A second screen

A screen is a view and ViewModel pair registered with the app. Settings is a second one, and the two share state: the app owns it, and any ViewModel reads it as self.state.

Settings_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Settings, typography_role: headline_medium}
  - id: name_label
    kind: Text
    classes: [muted]
    text: {content: Your name, typography_role: label_large}
  - id: name
    kind: TextField
    text: {content: "", typography_role: body_large}
    style: {width: 240, height: 40, foreground: on_surface, background: surface_container_high,
            corner_radius: 8, padding: {left: 12, right: 12, top: 0, bottom: 0}}
    bindings: {text: "{{ name.get() }}"}
    two_way: text
    a11y: {label: Your name}
  - id: buttons
    kind: Container
    classes: [row]
    children:
      - id: theme
        component: ButtonOutlined
        with: {label: Light or dark, width: 140, height: 40, corner_radius: 20}
        handlers: {on_click: toggle_dark}
      - id: back
        component: ButtonText
        with: {label: Back, width: 80, height: 40, corner_radius: 20}
        handlers: {on_click: go_back}
        bindings: {disabled: "{{ not app.can_go_back.get() }}"}
Settings_ViewModel.py
from tesserae import App, ViewModel


class SettingsViewModel(ViewModel):
    def __init__(self, view):
        self.name = App.of(view).state.user  # the shared Signal itself: a two_way binding needs one
        super().__init__(view)

    def toggle_dark(self):
        self.app.set_dark(not self.app.dark)  # every screen switches, in place

    def go_back(self):
        self.app.back()
Tasks_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Tasks, typography_role: headline_medium}
    bindings: {text: "{{ heading.get() }}"}
  - id: stats
    kind: Container
    classes: [row]
    children:
      - id: open
        component: Stat
        with: {label: Open, value: "{{ open_text.get() }}"}
      - id: finished
        component: Stat
        with: {label: Done, value: "{{ done_text.get() }}"}
  - id: buttons
    kind: Container
    classes: [row]
    children:
      - id: add
        component: ButtonFilled
        with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
        handlers: {on_click: add_task}
      - id: settings
        component: ButtonText
        with: {label: Settings, width: 100, height: 40, corner_radius: 20}
        handlers: {on_click: open_settings}
  - id: list
    kind: Container
    classes: [rows]
Tasks_ViewModel.py
from pathlib import Path

from tesserae import App, Computed, Repeater, Signal, ViewModel

from TaskItem_ViewModel import TaskItemViewModel

HERE = Path(__file__).parent


class TasksViewModel(ViewModel):
    def __init__(self, view):
        state = App.of(view).state  # `self.state` isn't there until super().__init__
        self.heading = Computed(lambda: f"{state.user.get()}'s tasks")
        self.tasks = Signal([])
        self._next_id = 1
        self.rows = Repeater(view, self.tasks, HERE / "TaskItem_View.yaml", TaskItemViewModel,
                             view.node("list"), args=lambda task_id: (task_id, self.tasks))
        self.done_text = Computed(lambda: str(self._done()))
        self.open_text = Computed(lambda: str(len(self.tasks.get()) - self._done()))
        super().__init__(view)

    def _done(self):
        self.tasks.get()
        return sum(1 for _key, _component, row in self.rows if row.done.get())

    def add_task(self):
        self.tasks.update(lambda ids: [*ids, self._next_id])
        self._next_id += 1

    def open_settings(self):
        self.app.navigate("Settings")  # a step the user can go back from
app.py
import sys
from pathlib import Path

from tesserae import App, Signal

from Settings_ViewModel import SettingsViewModel
from Tasks_ViewModel import TasksViewModel

HERE = Path(__file__).parent


class AppState:
    """What every screen shares: `self.state` in a ViewModel, `state` in a binding."""

    def __init__(self):
        self.user = Signal("Ada")


app = App(
    width=420,
    height=520,
    title="Tasks",
    custom_theme=HERE / "Brand_Theme.yaml",
    stylesheet=HERE / "Tasks_Stylesheet.yaml",
    state=AppState(),
)
app.load(HERE / "Tasks_View.yaml", TasksViewModel)
app.load(HERE / "Settings_View.yaml", SettingsViewModel)
app.route("", "Tasks")
app.route("settings", "Settings")

app.navigate_to(sys.argv[1] if len(sys.argv) > 1 else "")  # a route: `python app.py settings`
app.run()
  • state=AppState() gives every screen the same user Signal. Change the name in Settings and the heading on the Tasks screen follows.
  • app.navigate("Settings") is a step the user can go back from, and app.back() goes back. The Back button's disabled is bound to app.can_go_back, so it greys out when there is nowhere to go.
  • app.route(...) names screens by strings, so python app.py settings opens on Settings.
  • app.set_dark(...) switches every screen between light and dark, in place.

See Apps & Screens.

6. A custom window

With borderless: true the OS draws no title bar, and the app draws its own frame. The window view is that frame: a kind: Window file with a title bar (which gets the window's buttons), a navigation rail that switches screens, a status bar, and the screens themselves, each a view: with a route:. The app loads the one file, and the window's file now brings the two screens in.

Window_View.yaml
# The frame around the screens. With no OS title bar, the window's own title bar is the one at the top.
id: root
kind: Window
title: Tasks
borderless: true  # no OS title bar: the title bar below is the window's
min_width: 480
min_height: 360
style: {width: 640, height: 480, background: surface, flex_direction: vertical}
title_bar: {title: Tasks, style: {background: primary_container}, buttons: [minimize, maximize, close]}
children:
  - id: body
    kind: Container
    style: {flex: fill, flex_direction: horizontal}
    children:
      - id: nav
        component: NavigationRailScreens
        with:
          items:
            - {label: Tasks, icon: home, screen: Tasks}
            - {label: Settings, icon: settings, screen: Settings}
      - id: screens
        kind: Container
        style: {flex: fill}
        children:
          - {id: tasks, view: Tasks_View.yaml, route: "", style: {flex: fill}}
          - {id: settings, view: Settings_View.yaml, route: settings, style: {flex: fill}}
  - id: status
    component: StatusBar
    with: {text: Ready, width: "100%"}
Tasks_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Tasks, typography_role: headline_medium}
    bindings: {text: "{{ heading.get() }}"}
  - id: stats
    kind: Container
    classes: [row]
    children:
      - id: open
        component: Stat
        with: {label: Open, value: "{{ open_text.get() }}"}
      - id: finished
        component: Stat
        with: {label: Done, value: "{{ done_text.get() }}"}
  - id: add
    component: ButtonFilled
    with: {label: Add a task, width: 140, height: 40, corner_radius: 20}
    handlers: {on_click: add_task}
  - id: list
    kind: Container
    classes: [rows]
Tasks_ViewModel.py
from pathlib import Path

from tesserae import App, Computed, Repeater, Signal, ViewModel

from TaskItem_ViewModel import TaskItemViewModel

HERE = Path(__file__).parent


class TasksViewModel(ViewModel):
    def __init__(self, view):
        state = App.of(view).state  # `self.state` isn't there until super().__init__
        self.heading = Computed(lambda: f"{state.user.get()}'s tasks")
        self.tasks = Signal([])
        self._next_id = 1
        self.rows = Repeater(view, self.tasks, HERE / "TaskItem_View.yaml", TaskItemViewModel,
                             view.node("list"), args=lambda task_id: (task_id, self.tasks))
        self.done_text = Computed(lambda: str(self._done()))
        self.open_text = Computed(lambda: str(len(self.tasks.get()) - self._done()))
        super().__init__(view)

    def _done(self):
        self.tasks.get()
        return sum(1 for _key, _component, row in self.rows if row.done.get())

    def add_task(self):
        self.tasks.update(lambda ids: [*ids, self._next_id])
        self._next_id += 1
Settings_View.yaml
id: root
kind: Container
classes: [page]
children:
  - id: title
    kind: Text
    classes: [heading]
    text: {content: Settings, typography_role: headline_medium}
  - id: name_label
    kind: Text
    classes: [muted]
    text: {content: Your name, typography_role: label_large}
  - id: name
    kind: TextField
    text: {content: "", typography_role: body_large}
    style: {width: 240, height: 40, foreground: on_surface, background: surface_container_high,
            corner_radius: 8, padding: {left: 12, right: 12, top: 0, bottom: 0}}
    bindings: {text: "{{ name.get() }}"}
    two_way: text
    a11y: {label: Your name}
  - id: buttons
    kind: Container
    classes: [row]
    children:
      - id: theme
        component: ButtonOutlined
        with: {label: Light or dark, width: 140, height: 40, corner_radius: 20}
        handlers: {on_click: toggle_dark}
Settings_ViewModel.py
from tesserae import App, ViewModel


class SettingsViewModel(ViewModel):
    def __init__(self, view):
        self.name = App.of(view).state.user  # the shared Signal itself: a two_way binding needs one
        super().__init__(view)

    def toggle_dark(self):
        self.app.set_dark(not self.app.dark)  # every screen switches, in place
app.py
import sys
from pathlib import Path

from tesserae import App, Signal

HERE = Path(__file__).parent


class AppState:
    """What every screen shares: `self.state` in a ViewModel, `state` in a binding."""

    def __init__(self):
        self.user = Signal("Ada")


app = App(
    title="Tasks",
    custom_theme=HERE / "Brand_Theme.yaml",
    stylesheet=HERE / "Tasks_Stylesheet.yaml",
    state=AppState(),
)
app.load(HERE / "Window_View.yaml")  # the window: its title bar, its rail, and the screens in it

app.navigate_to(sys.argv[1] if len(sys.argv) > 1 else "")
app.run()
  • style: on the Window is the window's size and colour, and the title bar takes a style: too, so its colour is yours to set.
  • A view: with a route: is a screen: Tasks_View.yaml is Tasks, Settings_View.yaml is Settings, and each finds its ViewModel beside it, so app.py no longer loads or routes them one by one.
  • The rail replaces the Settings and Back buttons, so they are gone from the screens.
  • The window is still resizable (from its edges), and the title bar still drags it.

See Windows, Docks & Embedded Views and Custom Title Bars.

Where next

  • Run with app.run(hot_reload=True) and edit any view, stylesheet or theme while the app is open: the window updates and keeps its state. See Hot Reload.
  • tesserae build --check turns the app into one executable: Releasing Your App.
  • Browse the components, and the YAML and Python references.