The View Language¶
A view is a YAML file that says what is on the screen and how it reacts. One shape does everything: a node with a widget:
and the properties of that widget, a list of children:, and a few keys every node has. This page is the whole language; the rest of the
guide pages are about the widgets and the app around it.
name: counter
widget: Container
style: {flex_direction: vertical, width: 240, height: 120, gap: 12, padding: 16}
children:
- widget: Text
typography_role: title_large
text: "Count: {{ count }}"
style: {width: 200, height: 32, foreground: on_surface}
- widget: Rect
name: add
style: {width: 120, height: 40, background: primary, corner_radius: 8}
handlers: {on_click: increment}
a11y: {label: Add one}
Files are named <Name>_View.yaml, wherever they are in the project. A view with params: is a component: another view calls it by
its name, with the params as plain keys. There is no other difference.
Nodes¶
| Key | Meaning |
|---|---|
widget |
which widget: a built-in, one the app registered, or a view found by name |
name |
an optional handle, unique in the view (a letter or _, then letters, digits and _) |
if, for, key |
include the node while an expression is true; repeat it for each element (key is its identity) |
slot |
which slot of the view being called a child goes into |
state |
local state: names with starting values |
style |
layout and paint, and the extras the widget declares |
classes |
extra names a stylesheet rule can select |
handlers |
events to actions |
a11y |
accessibility: label, role, hidden, live, level, expanded, selected, checked, value, value_min, value_max, value_step |
interaction |
the hover, focus and press feedback: true, false or a colour role |
window_region |
drag or none, for a custom title bar |
route |
on a view call inside a window: makes it a screen |
focus_group |
horizontal, vertical or both: the focusable nodes under it share one tab stop and the arrow keys move among them |
children |
child nodes |
| anything else | a property of the widget |
An unknown key or property is an error that names the file, line and column and suggests the nearest one:
Main_View.yaml:11:5: error: Slider: no property 'valu' (did you mean 'value'; properties: value, disabled)
11 | valu: 3
| ^
Nodes have ids, assigned by the loader and never written: the path from the root, using a node's name where it has one
(root.add) and its place where it does not (root.children[0]), with [key] added under a for:. Give a node a name when something has to
find it again: a ViewModel, a stylesheet rule, a test, or a hot reload that should keep its state.
Widgets and properties¶
Every widget declares its properties: a type, a default, the values it allows, whether it is required and whether the user edits it (a model property). A value is a literal or an expression, and an expression's result is checked against the type.
| Widget | Properties |
|---|---|
Container, Rect |
none; they hold children (a Rect is the usual clickable box) |
Text, Link |
text, typography_role, font_family, font_size, font_weight, wrap, overflow, text_align, max_lines, letter_spacing; a Text also selectable and heading, a Link also href, visited, disabled and underline |
TextInput |
text (model), placeholder, multiline, obscured, max_length, read_only, typography_role, font_family, font_size, font_weight, disabled |
TextField |
the Material text field, a view Tesserae ships: see Text fields |
Icon |
icon (a built-in icon name, or path and view_box: one of them) |
Image, Svg |
src, fit; src, content |
Checkbox, Switch, RadioButton |
checked or selected (model), group, disabled |
Slider, SpinBox |
value (model), min, max, step, disabled |
CircularProgress, LinearProgress, LoadingIndicator |
value |
TimePickerDial |
hour, minute (model) |
ScrollView, NodeGraph, GraphNode |
a scrolling box; a canvas of linked nodes; a node of it |
Window, TitleBar, Dock, DockPanel |
see Windows, Docks & Embedded Views; written with widget: in a view opened with app.open_view, a Window root sets the OS window (title, borderless, minimum sizes) and a title bar's buttons, dimming and glyphs follow the app |
Slot |
where a calling view's children go |
State is a property (checked, selected, value, disabled), never a style. foreground is a style extra of the widgets that draw text or
glyphs, indicators and controls (Text, Link, TextInput, Icon, Svg, LoadingIndicator, LinearProgress, CircularProgress, Checkbox, RadioButton, Switch); anywhere else it is an error that names who accepts it.
Expressions¶
Everything inside {{ }} is one expression in a safe subset of Python: arithmetic, comparisons, and/or/not, x if c else y, f-strings,
lists, dicts, comprehensions, indexing, and a short list of functions and methods (len, min, max, sum, round, sorted, str,
format_number, pluralize, clamp, the date functions date_add_days, date_weekday, days_in_month, format_date and current_date, the usual text and list methods). Nothing else runs: no imports, no attribute that starts with _, no
calling an object's own methods, and every expression has limits on size, steps and the numbers it may make. See
Binding Expressions for the full list.
A Signal reads as its value: {{ count }} and {{ count.get() }} are the same, and the property follows the Signal. A value that cannot
change (only literals, params with fixed values, loop variables over a fixed list) is worked out once when the view is built.
Names are looked up in the loop variables, then the local state, then the params of the view the expression is written in, then the
ViewModel, then the app (app.current_screen), then the built-in functions. hovered, focused and pressed are the interaction state of the
nearest widget with interaction.
Views calling views¶
params:
title: {type: str, required: true}
variant: {type: enum, choices: [elevated, outlined], default: elevated}
widget: Container
children:
- widget: Text
name: heading
text: "{{ title }}"
typography_role: title_medium
style: {foreground: on_surface}
- widget: Slot
- widget: Slot
name: actions
params: is a list of names ([title, icon], each required unless written {icon: null}) or a mapping of names to a type or to
{type, default, choices, required, model, doc}. The types are str int float bool color length icon enum list dict node nodes handler any.
A call gives the params as keys, and its children: are the slot content. An expression written at the call is read where it is written,
in the caller's names, and it stays reactive inside the view. The view cannot see the caller's names. Children go in the default Slot, or in
the one named by their slot:; a view with no slot takes no children.
A call's own style, classes, a11y, interaction and handlers lie over the called view's root, and a handler written at the call runs in
the caller's names. A view may call itself (a tree), to a depth of 64.
A param of type handler takes the caller's action name or statements (on_remove: "items = [x for x in items if x != row]"), read in the caller's names. The view runs it by calling the param like an action, on_remove(); a param the caller left out does nothing when called.
A param that is model: true and is given a bare Signal (on: "{{ dark }}") passes the Signal itself, so the view's own Switch edits the
caller's value.
Repeating, choosing and remembering¶
widget: Container
state: {open: false}
children:
- for: row in rows
key: row.id
widget: Text
name: row
text: "{{ row.title }}"
handlers: {on_click: "open_row(row.id)"}
- widget: Text
if: len(rows) == 0
text: Nothing here
- widget: Rect
name: toggle
style: {width: 40, height: 40, background: primary}
handlers: {on_click: "open = not open"}
for:takesitem in items, ori, item in enumerate(items). The loop variable is in scope for the node, its children and its handlers. If the iterable reads something that can change, the loop is reactive and needs akey:: when the list is replaced, nodes whose key stays are kept, new keys are built, removed keys are gone, and the order follows the list. A list changed in place is not noticed; replace it.if:builds the node only while its expression is true, and a node under a falseif:runs nothing. With both on one node, theif:is tested for each element.state:makes a Signal for each name, for this node and everything under it. A starting value may be an expression over the view's own params, which seed it once when the state is made (state: {draft: "{{ hour }}"}); it may not read a ViewModel's names. It belongs to the node (to each row under afor:), is written by handlers, and survives a hot reload for a node that keeps its name or key. Use it for presentation (open, on); what the app cares about belongs in the ViewModel.for:,if:andslot:are not allowed on the root of a view.
Handlers¶
widget: Rect
handlers:
on_click: save # a ViewModel method
on_hover_enter: "hint = 'Save'" # an assignment
on_change: "query = event.value; page = 0" # several, separated by ;
on_tap: window.close # a built-in action
An event is one of on_click, on_hover_enter, on_hover_exit, on_change, on_focus_enter, on_focus_exit, on_tap, on_long_press,
on_pan, on_pinch, on_touch_start, on_touch_move, on_touch_end, on_touch_cancel, on_file_hover, on_file_hover_cancel,
on_file_drop, on_link, on_key (a key pressed), on_submit (Enter in a field that is not multiline), on_press (the pointer pressed on the node or
anything in it; unlike on_click it does not make the node a button), on_move (the pointer moved over it, or anywhere while it holds the pointer) and on_release (the button let go); a misspelt one is an error.
A handler is a dotted name (save, window.close, surface.dismiss,
navigate.back, navigate.Settings), or statements, which may call focus('name') to give the focus to the node of that name in the view the handler
is written in: an assignment (=, +=, -=, *=, /=) to local state or a ViewModel Signal, or a call
of a ViewModel method (navigate_to(screen) goes to a screen held in a name). event is in scope. Assigning to a param, a loop variable or
anything else is an error; so is if, for or any other statement. A disabled node's handlers do not run.
Timers¶
after(ms, action) runs action once after ms milliseconds; every(ms, action) runs it every ms until cancelled. action is text, run in the
scope the handler was written in: an action name ('bump') or statements ('shown = False'). A third argument names the timer so cancel('name')
can stop it, and starting one of the same name again restarts it (a hover delay, a debounce). A name belongs to the scope that wrote it, as a local
name does: two items of a for: each have their own. A timer stops when its view closes or the widget that started it is gone. Times are
frame-quantized: a timer fires on the first frame after its time is up. A mistake in a literal action text is found when the view loads.
widget: Container
handlers:
on_click: "shown = True; after(2000, 'shown = False', 'dismiss')"
on_hover_exit: "cancel('dismiss')"
For Python, tesserae.timers.Timers(window) has the same after, every, cancel and cancel_all.
Scrolling¶
A ScrollView draws its scroll_offset (pixels, 0 or more; tre clamps it to the content once the content has a size) and reports where it is to
whatever you bind to it, so other widgets can react. Each of these is bound the way a two-way property is, to a ViewModel Signal or a state: name:
| Property | Meaning |
|---|---|
scroll_offset |
Two-way: setting the Signal scrolls, and scrolling writes the Signal. |
at_top, at_end |
Scrolled to the start, or as far as it goes. at_end is false until the content has been laid out. |
scroll_direction |
'down' or 'up' for the last scroll, 'none' before the first. |
widget: Container
state: {top: true, way: none}
children:
- widget: ScrollView
at_top: "{{ top }}"
scroll_direction: "{{ way }}"
children: []
- widget: Container
if: top or way == 'up' # a bar that hides while the list scrolls down
A view scrolls vertically, or sideways with orientation: horizontal; a ScrollView that nothing is bound to keeps its own position.
Input masks¶
mask: on a TextInput (or a TextField) puts what the user types or pastes in a pattern: # is a digit, A a letter, * a letter or digit, \
makes the next character a literal, and any other character is a literal that comes by itself.
widget: TextField
label: Phone
text: "{{ phone }}"
mask: "(###) ###-####" # 5551234567 becomes (555) 123-4567; so does 555-123-4567
The text is read left to right and each slot takes the next character it accepts, so a paste in any format is fitted. A literal is added only
when more input follows it (123 is (123, the ) comes with the fourth digit), so backspace is never stuck on one; one the user typed is kept; what
does not fit is dropped. The bound Signal holds the formatted text. The mask is applied to edits, not to a value a ViewModel sets, and tre's input
has no caret control yet, so where the caret ends up after the text is rewritten is tre's to decide.
Drawing¶
widget: Canvas is a surface you draw on with data. draw: lists commands painted in order: {rect: [x, y, width, height]},
{circle: [cx, cy, radius]} and {path: [point, ...], width: 2}, each with a color (a theme role, a CSS colour or role@N%). In a path the
first point is [x, y] and each later one is a line [x, y], a quadratic [cx, cy, x, y] or a cubic [c1x, c1y, c2x, c2y, x, y]. Numbers are
logical pixels from the canvas's top-left; size the canvas with style. Write draw: "{{ [...] }}" and it repaints when a Signal it reads changes.
widget: Canvas
style: {width: 120, height: 24}
draw: "{{ [{'rect': [0, 10, 120, 4], 'color': 'surface_variant'}, {'rect': [0, 10, 120 * progress, 4], 'color': 'primary'}] }}"
Dragging¶
capture() makes the node whose handler is running receive every pointer event until the button is let go or release() is called, so a drag
keeps following the pointer outside the node. cursor('grabbing') sets that node's pointer shape (any of tre's names: default, pointer, text, grab,
grabbing, move, not_allowed, col_resize, ...; cursor(None) puts back the one its style gives). Position and distance are in event
(event.x, event.y; a pan's event.delta_x). They act on the widget the handler is written on, so a timer's action cannot call them.
widget: Container
handlers:
on_press: "dragging = True; capture(); cursor('grabbing')"
on_move: "x = event.x if dragging else x"
on_release: "dragging = False; release(); cursor(None)"
The clipboard¶
copy(text) puts text on the OS clipboard (a number is copied as text) and says whether it could; paste() is the text on it, or '' when it holds none or
cannot be reached. Both work in a handler, and paste() can be used where a value is wanted:
widget: Container
handlers:
on_click: "ok = copy(name)"
on_long_press: "name = paste()"
Opening a link¶
open_url(url) hands a link to the OS (the default browser or mail program) and says whether it could. Only http, https, mailto and tel links
are opened: a view's text can come from anywhere, and file:, javascript: or an application's own scheme would run something rather than show a page, so
those are an error naming the call. For Python, tesserae.urls.open_url(url) is the same.
widget: Link
text: Read the docs
handlers: {on_click: "open_url('https://example.com/docs')"}
Tooltips¶
Any node can have a tooltip:. A plain one is text; a rich one adds a title, up to two actions, or both.
widget: Container
tooltip: Delete # or a {{ }} expression
widget: Container
tooltip: {title: Delete, text: Removes the file for good, delay: 300}
widget: Container
tooltip:
title: Delete
text: Removes the file for good
placement: above # below (the default), above, start or end
actions: # one or two text buttons; each is a handler
- {label: Undo, on_click: undo}
- {label: Close, on_click: "hint = 'closed'"}
It shows next to the node after the pointer has rested on it for delay milliseconds (500 by default), or at once when keyboard focus arrives (a click
that focuses it does not show it). It goes when the pointer leaves, when the node is pressed, when focus leaves or on Escape, and it never takes the
pointer or a press. A plain tooltip is the inverse surface and cannot be entered; a rich one (a title or actions) is a raised surface with the title above the text, and it
stays while the pointer moves onto it, going a moment after the pointer has left both. Pressing an action runs its handler and closes the tooltip. A touch
held in place shows it too. The text is also given to a screen reader as the node's description where the node has none. A tooltip on a view call goes to
the root of the view called. The actions are for the pointer: the keyboard shows the tooltip and Escape closes it.
Accessibility¶
a11y: says what a node tells assistive technology: label, role, hidden, live, level, and the states expanded, selected, checked,
value, value_min, value_max, value_step, pressed (true, false or mixed), invalid, busy, description, current (page, step,
location, date, time or true), value_now and value_text. A value may be an expression. describedby and controls name another node of
the view by its name:, or a list of them.
widget: Container
name: menu_button
a11y: {pressed: "{{ open }}", controls: menu, description: "{{ hint }}"}
The engine does not have properties for pressed, invalid, description, current, value_now, value_text, busy, describedby and controls yet
(they are requested of it), so they are checked, kept, and sent as far as the engine takes them: on an engine without one, that field is skipped and a
warning names it once. The components set the ones they mean (a text field sets invalid and description).
Moving between screens¶
app.transition sets how navigate, back and forward change screens; the default, none, is a swap, and show() is a jump that never animates.
| Transition | What the user sees |
|---|---|
fade_through |
the old screen fades out (90 ms), then the new one fades in and settles from 92% (210 ms) |
shared_axis_x, shared_axis_y |
the screens slide 30 px along the axis as they fade, forward one way and back the other |
shared_axis_z |
the old screen grows away as it fades, and the new one grows in from 80% |
container_transform |
the new screen grows from a node to fill its place, and going back shrinks into it |
app.transition = "shared_axis_x"
app.navigate_with("detail", transition="container_transform", origin=card_node, params={"id": 3})
back() undoes how the screen it leaves was reached, and forward() repeats it. A navigation while one is playing finishes that one first, and an app that
reduces motion swaps at once. The screens of a window view (routed views) change the same way.
The window and the app¶
app is readable in any expression and follows the window, so a layout can change with its size:
| Name | Meaning |
|---|---|
app.window_width, app.window_height |
the window's size in logical pixels |
app.width_class |
Material 3's window width class: compact (under 600), medium, expanded (840), large (1200) or extra_large (1600) |
app.height_class |
compact (under 480), medium or expanded (900) |
app.maximized, app.active |
whether the window is maximized, and has the focus |
widget: Container
children:
- {widget: Container, name: rail, if: "app.width_class != 'compact'", style: {width: 80, height: 40}}
- {widget: Container, name: bar, if: "app.width_class == 'compact'", style: {width: 80, height: 40}}
A ViewModel that has an app of its own keeps it. tesserae.tokens.width_class(width) and height_class(height) give the classes for a size in Python.
Long lists¶
A for: builds every row it makes, which is fine for a screenful and not for ten thousand. widget: VirtualList is a scrolling list of equal-height
rows that builds only the rows in view, and more as it scrolls. Its one child is the for:; write the row once.
widget: VirtualList
item_height: 48
overscan: 3 # rows built past each edge (the default)
scroll_offset: "{{ y }}" # as for a ScrollView, with at_top, at_end and scroll_direction
children:
- widget: Container
for: row in rows
key: row.id
handlers: {on_click: "picked = row.id"}
children:
- {widget: Text, text: "{{ row.name }}", typography_role: body_large, style: {foreground: on_surface}}
The list is as tall as all its rows, and each row built sits at its place in it, so a row's own style sets its width, background and so on but not its
height or position. Rows leave and come as the list scrolls: a row's local state: is not kept across that. Rows must all be item_height tall. Opening a
list of 10 000 builds about a dozen rows.
Overlays¶
widget: Overlay shows its children in a layer over the window while open is true: a menu, a popover, a dialog. It takes no room where it is written.
widget: Overlay
open: "{{ menu_open }}"
anchor: trigger # the name of a node in this view; the layer sits against it
placement: below # or above, start, end; it flips or shifts to fit
style: {width: 160, background: surface_container}
children:
- {widget: Text, text: Rename, typography_role: label_large, style: {foreground: on_surface}}
timeout: 4000 closes it by itself that many milliseconds after it opens (the pointer over it holds the time off), and handlers: {on_dismiss: ...} runs whenever it closes itself or the user closes it.
Escape and a press outside close it, and write false to the Signal open is bound to; with an open that is not a Signal it stays closed until open
goes false. Focus goes back to where it was. dismissible: false turns both off.
modal: true makes the layer a scrim (the scrim colour at 32%) over the whole window, blocking input to what is under it and keeping Tab inside; it
follows the window's size and centres its children unless the style places them (align_content). Put the dialog itself in a child, with its own size
and background. A press on the scrim closes a dismissible modal; a press on its content does not. Without an anchor and not modal, the layer sits at its own x and y.
Focus¶
focus('input') in a handler gives the focus to the node named input in the same view (names are unique in a view, and a view never reaches into
the one it calls). on_press: "focus('input')" on a container makes a click anywhere in it, padding included, land in the field inside it.
A focus_group makes a toolbar, a tab strip, a menu or a radio set behave as one control: Tab visits one of its items (the one that last had
the focus), the arrow keys of its axis move the focus to the next and the previous item (wrapping), Home and End go to the first and last, and typing
the start of an item's name jumps to it (the same letter again goes on to the next item that starts with it; a pause of a second starts a new search; typing in
a text field inside the group is the field's). Disabled items are skipped, and a group inside a group looks after its own items.
Two-way properties¶
A model property given one bare reference to a Signal or local state is two-way: the user's edit is written back to it. Any other
expression is one-way, and the widget shows the expression's value.
widget: Switch
selected: "{{ dark }}" # toggling writes `dark`
Style¶
style: holds layout and paint: size and spacing (width height min_width max_width min_height max_height aspect_ratio padding margin),
flex and grid, alignment, position, background border_width border_color corner_radius opacity elevation, effects (blur backdrop_blur
blend_mode filter cursor clip_children), and the widget's extras. Values may be expressions.
A stylesheet (<Name>_Stylesheet.yaml) holds rules, so a look is written once and not on every node:
styles:
- widget: Card
style: {corner_radius: 12}
- widget: Card
variant: outlined
style: {border_width: 1, border_color: outline}
- widget: Card
part: heading
state: hovered
style: {foreground: primary}
A rule names a widget (a built-in or a view), and may add variant, size, shape (properties the widget declares), classes, name, a
part (a named node inside that widget's view) and a state (hovered focused focus_visible pressed disabled selected checked expanded error read_only visited; the last seven read the
widget's own property). The most specific rule wins: a name, then the number of properties and classes matched, then a state, then the
widget alone, and a later rule wins a tie. A widget's own shipped looks are the lowest layer and the app's stylesheet is above them. A
node's inline style: beats every rule, for the fields it sets and no others. A rule's value may be an expression over the widget's params.
ViewModels¶
A view with a name: is bound to the ViewModel that serves that name; a view without one is a static visual.
class DataViewModel(ViewModel):
views = ["data_pie", "data_list"] # the views it serves
def __init__(self):
super().__init__()
self.rows = Signal([...])
app = App(root=".")
app.bind(DataViewModel)
app.open_view("DataPie")
app.open_view("DataList")
One instance serves every view that names it, wherever they are, so a pie chart and a list of the same rows are two views of one
Signal. app.bind(DataViewModel) makes it once, when the first view opens; app.bind(instance) uses yours; app.bind(factory=DataViewModel)
makes one per view instance. The ViewModel finds its open views in self.views[name]: .node(name), .state(var), .show(), .hide(), and
self.show("data_phone", "data_list") shows one in place of another. on_attached(view) and on_detached(view) are called as views open
and close.
app.check() compares every name, action and expects: entry of the open views with their ViewModels without a window. expects: is optional
data in a view's header: expects: {count: int, rows: list, add: handler}.
Tools¶
tesserae migrate-yaml [PATH] [--write] [--force]moves a project written in the older syntax to this one: Migrating.- A JSON schema of every widget's node, with its properties, ships in the package as
schema/tesserae-widget-schema.json.