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.
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}
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}"])
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: ButtonFilledputs MD3's filled button in place;with:fills its parameters. Every component has a page. - State.
tasksis aSignal;summaryis aComputedthat follows it. The view'sbindings:keeps the text showing it, andhandlers:callsadd_taskon a click. - The app.
app.loadpairs the two files by name;theme_seedmakes 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.
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}
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])
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%}
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
tasksholds the ids and is the single source of truth. TheRepeatermakes aTaskItemfor each id, andargshands its ViewModel the id and the list.- A row removes itself by changing the list; nobody tears the row down by hand.
two_way: checkedwrites the checkbox back to the row'sdoneSignal.
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:
seed: "#0B6B58"
typography:
headline_medium: {font_weight: 600}
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}
id: row_style
style:
flex_direction: horizontal
align_content: left
gap: 8
width: 100%
# 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}
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]
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}
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: oneseedcolour makes the whole palette, andtypography:changes a type style. Pass it asApp(custom_theme=). - A stylesheet (
Tasks_Stylesheet.yaml) is rules. A node saysclasses: [page]and a ruleclasses: [page]styles it; rules can also match akindor anid. Pass it asApp(stylesheet=). - A style file (
row_Style.yaml) is one node'sstyle:, shared:style: row_Style.yaml. - A component's own stylesheet. Built-in components keep their look in a
stylesheet. A
ButtonFilled_Stylesheet.yamlnext 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.
# 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}
# 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}
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]
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
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: StatfindsStat_Component.yamlnext to the view. Its parameters arelabelandvalue.valueis a binding expression, so each stat keeps up with itsComputed. (A binding to text needs a string, so theComputeds 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.
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() }}"}
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()
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]
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
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 sameuserSignal. 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, andapp.back()goes back. The Back button'sdisabledis bound toapp.can_go_back, so it greys out when there is nowhere to go.app.route(...)names screens by strings, sopython app.py settingsopens 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.
# 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%"}
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]
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
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}
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
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 theWindowis the window's size and colour, and the title bar takes astyle:too, so its colour is yours to set.- A
view:with aroute:is a screen:Tasks_View.yamlisTasks,Settings_View.yamlisSettings, and each finds its ViewModel beside it, soapp.pyno 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 --checkturns the app into one executable: Releasing Your App.- Browse the components, and the YAML and Python references.