Skip to content

Tutorial: The Same App as a Project

The Tutorial builds a Tasks app in one flat folder, naming every file by its path. This tutorial builds the same app as a project: the folder tesserae new makes, with a place for each kind of file, where Tesserae finds a file by its name. Read it beside the first one, or instead of it: the app, its screens and its behaviour are the same, and what changes is where the files are and how little code it takes to join them.

Every step is a project folder in examples/tutorial_project/ that Tesserae's tests run. The page shows every file a step adds or changes, so each step can be followed on its own; the flat Tutorial explains the views and ViewModels in more depth.

Flat or a project?

A flat folder A project
Made by you, file by file tesserae new tasks
Layout every file side by side Views/, ViewModels/, Components/, Themes/, Styles/
Load a screen app.load(HERE / "Tasks_View.yaml", TasksViewModel) and an import app.load("Main")
Rows of a list Repeater(view, items, HERE / "TaskItem_View.yaml", TaskItemViewModel, into=...) Repeater(view, items, "TaskItem", into=...)
A theme or stylesheet App(custom_theme=HERE / "Brand_Theme.yaml") App(custom_theme="Brand")
A component of your own next to the view that uses it Components/, from any view
A new screen write a view, a ViewModel, the import and the route tesserae add screen Settings
Good for one screen, a sketch, a single file to share an app with several screens and its own look

Neither is a mode: a project is a flat folder with the files sorted, and paths still work in a project. Start flat and sort the files into folders when there are too many to find; or start with tesserae new and never write a path.

1. A new project

tesserae new tasks
cd tasks
source .venv/bin/activate     # Windows: .venv\Scripts\activate
python app.py

tesserae new makes the folders, a Main screen that counts clicks, and a virtual environment with Tesserae in it (--no-venv skips the environment). Replace the screen with the Tasks app's:

tasks/
  app.py
  Views/Main_View.yaml
  ViewModels/Main_ViewModel.py
  Components/  Themes/  Styles/       empty for now
Views/Main_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}
ViewModels/Main_ViewModel.py
from tesserae import Computed, Signal, ViewModel


class MainViewModel(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

HERE = Path(__file__).parent

app = App(width=420, height=520, title="Tasks", root=HERE, theme_seed=(0x0B, 0x6B, 0x58, 0xFF))
app.load("Main")  # Views/Main_View.yaml and ViewModels/Main_ViewModel.py
app.show("Main")
app.run()
  • Paired by name. app.load("Main") opens Views/Main_View.yaml and the class MainViewModel in ViewModels/Main_ViewModel.py. There is no import and no path: the flat app.py needed both.
  • root=HERE says where the project is, so it runs from any folder: python tasks/app.py.
  • The view and ViewModel are the flat Tutorial's, renamed Tasks to Main.

2. A list of components

A task row is a view and ViewModel pair of its own, in the same two folders:

Views/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}
Views/Main_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%}
ViewModels/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])
ViewModels/Main_ViewModel.py
from tesserae import Computed, Repeater, Signal, ViewModel


class MainViewModel(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, "TaskItem", into=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
  • Repeater(view, self.tasks, "TaskItem", into=...) finds Views/TaskItem_View.yaml and TaskItemViewModel by the name. The flat version spells out the path and imports the class.
  • The folders keep views and Python apart, so a screen's ViewModel never imports another file's path.

3. Styling in files

Themes, stylesheets and style files each have a folder, and an app or a view names them:

Themes/Brand_Theme.yaml
seed: "#0B6B58"
typography:
  headline_medium: {font_weight: 600}
Styles/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}
Styles/row_Style.yaml
id: row_style
style:
  flex_direction: horizontal
  align_content: left
  gap: 8
  width: 100%
Components/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}
Views/Main_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]
Views/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

HERE = Path(__file__).parent

app = App(
    width=420,
    height=520,
    title="Tasks",
    root=HERE,
    custom_theme="Brand",  # Themes/Brand_Theme.yaml: the colours, made from one seed
    stylesheet="Tasks",    # Styles/Tasks_Stylesheet.yaml: the look of every screen
)
app.load("Main")
app.show("Main")
app.run()
  • custom_theme="Brand" is Themes/Brand_Theme.yaml, and stylesheet="Tasks" is Styles/Tasks_Stylesheet.yaml.
  • style: row_Style.yaml in a view is found in Styles/ (a style file next to the view is still found first).
  • A built-in component's stylesheet goes over its own when it is in Components/: the tertiary button.

4. A component of your own

A fragment goes in Components/, and any view says component: Stat, wherever the view is:

Components/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}
Components/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}
Views/Main_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]
Styles/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}
ViewModels/Main_ViewModel.py
from tesserae import Computed, Repeater, Signal, ViewModel


class MainViewModel(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, "TaskItem", into=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
  • Components/Stat_Component.yaml is the structure and Components/Stat_Stylesheet.yaml its look, as in the flat Tutorial, but one folder holds every component, so reusing one in another screen needs no path.

5. A second screen

tesserae add screen Settings

writes Views/Settings_View.yaml and ViewModels/Settings_ViewModel.py, and adds app.load("Settings") and its route to app.py. Fill them in, and share state through the app:

Views/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() }}"}
Views/Main_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]
ViewModels/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()
ViewModels/Main_ViewModel.py
from tesserae import App, Computed, Repeater, Signal, ViewModel


class MainViewModel(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, "TaskItem", into=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

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", root=HERE, custom_theme="Brand", stylesheet="Tasks", state=AppState())
app.load("Main")
app.load("Settings")
app.route("", "Main")
app.route("settings", "Settings")

app.navigate_to(sys.argv[1] if len(sys.argv) > 1 else "")
app.run()
  • Nothing is imported: app.load("Settings") pairs the two files, and the route settings opens it (python app.py settings).
  • state=AppState() is shared as it is in the flat Tutorial: the app owns it and a ViewModel reads self.state.

6. A custom window

The app's frame is one more view, a kind: Window file in Views/, loaded by name. It holds the title bar, a rail, a status bar, and the two screens as view: nodes with a route::

Views/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: Main}
            - {label: Settings, icon: settings, screen: Settings}
      - id: screens
        kind: Container
        style: {flex: fill}
        children:
          - {id: main, view: Main_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%"}
Views/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}
ViewModels/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
Views/Main_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]
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",
    root=HERE,
    custom_theme="Brand",
    stylesheet="Tasks",
    state=AppState(),
)
app.load("Window")  # Views/Window_View.yaml: its title bar, its rail, and the screens in it

app.navigate_to(sys.argv[1] if len(sys.argv) > 1 else "")
app.run()
ViewModels/Main_ViewModel.py
from tesserae import App, Computed, Repeater, Signal, ViewModel


class MainViewModel(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, "TaskItem", into=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
  • The rail replaces the Settings button, so Main_ViewModel.py loses open_settings.
  • app.load("Window") opens Views/Window_View.yaml. Its view: Main_View.yaml and view: Settings_View.yaml nodes, with a route:, are the screens: each is registered under its file's name (Main, Settings) and finds its ViewModel in ViewModels/, so app.py no longer loads or routes them.
  • The window's borderless: true and title_bar: make the window's own title bar; the rail is a NavigationRailScreens whose screen: names the screens.

Where next

  • Projects: the rest of finding files by name, more folders to search (search=), looking through the whole project (recursive=True), and what a name in two places does.
  • The tesserae command: new, add screen, build.
  • Tutorial: the same app with every file named by its path, and its YAML for each step.