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
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 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}"])
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")opensViews/Main_View.yamland the classMainViewModelinViewModels/Main_ViewModel.py. There is no import and no path: the flatapp.pyneeded both. root=HEREsays where the project is, so it runs from any folder:python tasks/app.py.- The view and ViewModel are the flat Tutorial's, renamed
TaskstoMain.
2. A list of components¶
A task row is a view and ViewModel pair of its own, in the same two folders:
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}
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 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])
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=...)findsViews/TaskItem_View.yamlandTaskItemViewModelby 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:
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
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"isThemes/Brand_Theme.yaml, andstylesheet="Tasks"isStyles/Tasks_Stylesheet.yaml.style: row_Style.yamlin a view is found inStyles/(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:
# 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]
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}
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.yamlis the structure andComponents/Stat_Stylesheet.yamlits 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:
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() }}"}
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 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()
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
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 routesettingsopens it (python app.py settings). state=AppState()is shared as it is in the flat Tutorial: the app owns it and a ViewModel readsself.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::
# 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%"}
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
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]
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()
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.pylosesopen_settings. app.load("Window")opensViews/Window_View.yaml. Itsview: Main_View.yamlandview: Settings_View.yamlnodes, with aroute:, are the screens: each is registered under its file's name (Main,Settings) and finds its ViewModel inViewModels/, soapp.pyno longer loads or routes them.- The window's
borderless: trueandtitle_bar:make the window's own title bar; the rail is aNavigationRailScreenswhosescreen: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
tesseraecommand:new,add screen,build. - Tutorial: the same app with every file named by its path, and its YAML for each step.