Binding Expressions¶
A {{ }} binding keeps a widget property in step with your ViewModel:
- id: label
kind: Text
text: {content: "", font_family: Roboto, font_size: 16}
bindings: {text: "{{ title.get() }}"}
The expression is evaluated against the ViewModel when the view is
attached. Every Signal or Computed it reads becomes a dependency, and
a change to one evaluates it again and updates the property.
An expression is a Python subset, run by a sandboxed
evaluator (tesserae.expr): nothing in a view file can import, open a
file, reach __class__ or run code the application did not expose. The
full grammar and limits are in design/yaml-language.md section 8.
A binding can drive text, sizes, colours and other numbers, checked
and selected, and visible: False takes a node out of the
layout and out of hit-testing, not just out of sight. There is a
conditional expression: {{ 'Restore' if app.maximized else 'Maximize' }}.
What you can write¶
| Examples | |
|---|---|
| a ViewModel attribute | count, title |
| an attribute of a value | user.name, pt.x; a dict reads by attribute too (row.title) |
| indexing and slices | items[0], table["key"], items[n - 1], name[:3] |
| arithmetic | a + b, a - b, -a, a * b, a / b, a // b, a % b, a ** 2 |
| comparison | ==, !=, <, <=, >, >=, in, not in, is None |
| logic and choice | a and b, a or b, not a, x if cond else y |
| literals and displays | 42, 1.5, "text", True, None, [1, 2], {"a": 1}, f"{n} items" |
| comprehensions | [t.upper() for t in tags if t] |
| built-in functions | len, min, max, sum, abs, round, sorted, str, int, float, range, format_number, pluralize, clamp, ... |
| methods of text, lists and dicts | name.upper(), items.index(x), d.get("k") |
A Signal or Computed reads as its value: count and count.get() are
the same, and both keep the binding in step when it changes.
Names and attributes starting with _ do not exist for an expression.
A call is only a built-in function, a method of text, a list or a dict, or
name.get() on a Signal. An object the application owns can be read
(its public attributes), but it cannot be added, compared, formatted or
iterated by an expression, so its own methods never run from a view.
An a11y: field can be bound the same way (label, hidden and
level): see Interaction & Accessibility.
Video frames¶
An Image takes a frame binding: a value (rgba, width, height),
width * height * 4 bytes of RGBA and the frame's size. Each new value
is shown as it arrives, and None keeps the last one. Decoding the video
is the app's: a ViewModel sets a Signal of the latest frame, and the
view pushes it. The latest frame survives a re-theme and a reconcile.
- id: screen
kind: Image
image: {fit: fill}
style: {width: 640, height: 360}
bindings: {frame: "{{ player.frame.get() }}"}
The Video fragment is that Image: component: Video with width,
height, fit and frame (the binding expression, as a string).
Python's rules¶
Arithmetic and comparison are Python's: 1 == 1.0 is True, integers
do not wrap (an int over 4096 bits is an error), 7 / 2 is 3.5 and
7 // 2 is 3. Dividing by zero is an error. and and or return one
of their operands (name or "Untitled"), and not returns True or
False.
Every expression has limits, and a breach is an error that names it: 2000
characters of source, 1000 nodes, nesting 40 deep, 200 000 evaluation
steps, range up to 100 000, a result of up to 1 000 000 items,
** with an exponent up to 64, and text up to 1 MB. % formatting and
str.format are not available; use an f-string.
When a binding is wrong¶
A binding that doesn't parse, or fails to evaluate, raises ValueError
when the view is attached, naming the widget, the property and the
expression, with the position in the expression and a hint when there is
one:
widget "label" binding on "text" ("{{ missing.get() }}"): 'missing' is not defined (did you mean 'min')
A function name that does not exist is found when the view loads, with the line, not when the expression runs. In a template, an if:,
a for: or a stylesheet rule the only functions are the built-in ones (clamp, min, max, len, format_number, ...), so
{{ limit(x, 0, 1) }} is a load error naming limit and offering the near names (did you mean 'clamp'). In a handler a call may also be
a built-in action (copy, focus, after, navigate_to, ...), a method of the ViewModel or a handler parameter; a name that is none of
these is a load error too (bmup(2) offers bump).
Who evaluates it¶
Tesserae does: tesserae.binding is a thin layer over tesserae.expr, in
a Tesserae View (tesserae.view.View).
A binding that changes a value sets it; one whose value hasn't changed
does nothing. Setting a value from a binding never counts as the user's
edit, so on_change runs only when the user changes the widget: typing in
a text field, or toggling a checkbox.