Skip to content

Flow and data

The tour continues: what handlers can do, functions as values, arithmetic with CPython's meaning, lists, charts, dicts and tuples.

Handlers and control flow

Handlers can be passed in three forms: a lambda (a tuple for multiple operations, lambda: (a.set(x), b.set(y))), a module-level def, and a store's bound method (on_click=Cart.clear).

A decorator compiles too. Decoration happens at import and the compiled app never runs the module, so the wrapper is folded into the handler it decorates:

def announced(f):
    def wrapper():
        status.set("working")
        f()
        status.set("done")

    return wrapper


@announced
def save():
    fs.write_text(path, body())

The decorator is a def of one argument that either returns that argument or defines a wrapper calling it once. A decorator that takes arguments of its own, or calls the function twice, or uses its value, is refused.

The body of a def handler compiles with its real control flow.

def double(v: int) -> int:          # a pure helper becomes a native function
    return v * 2


def tally():
    total.set(0)
    for i in range(1, 6):
        if i == 3:
            continue
        total.set(total() + double(i))

Available: if / elif / else, while (while True: included), for (over range(), list states, list fields, list-typed parameters), break / continue, and locals (reassignable, as in Python). print(...) writes to stdout, with sep= and end= as Python has them; log("…") writes a line to stderr instead. Both runs print the same bytes, and the gate compares what an app printed the way it compares the screens — a headless run's dump goes to the file PIXIE_DUMP names, so the two channels never mix. assert / raise end the statement the way Python's exception does — the app keeps running. Conditions take a bool directly (if on:), chain comparisons (0 < n < 10, the middle read once), and bind with :=. A comparison is also a value: flag.set(n() > 1), ok = a == b, and a helper that answers bool. A conditional expression (a if c else b) works anywhere one does in Python — in a handler and in a view — over int, float, str or bool. A pure helper (parameters and return annotated, body ending in return expression) is callable from handlers and from view text; it may return early from a branch, call itself, take list[...] parameters and default arguments, and return a value class or a list. A store or model method may answer T | None, with return None for the empty half; the caller narrows it (v = Bag.pick(), then if v is not None:).

A local assigned in both the if and the else reads fine after the branch, as in Python.

def judge():
    n = score()
    if n > 20:
        verdict = "high"
    else:
        verdict = "low"
    grade.set(verdict)      # readable after the branch

Reading a local assigned in only one branch is refused (had the branch not run, Python would raise NameError there).

Optional narrowing is written with the walrus.

if (v := sel()) is not None:
    text(f"picked {v}")      # v is bound only inside this branch
else:
    text("(none)")

Functions as values

A function is a value here: a lambda kept in a local, a nested def, a callback a store is armed with, an argument another function takes. Its type is written with Callable, and that annotation is what the compiled closure is built from — the types are never read off the body, so every closure goes somewhere that says what it takes.

from typing import Callable


@store
class Pipeline:
    n: int = 1
    step: Callable[[int], int] = lambda x: x + 1   # a callback field

    def advance(self) -> None:
        f = self.step
        self.n = f(self.n)

    def harder(self) -> None:
        self.step = lambda x: x * x               # armed with another

    def apply(self, g: Callable[[int], int]) -> None:
        self.n = g(self.n)                        # a parameter takes one


def offset() -> None:
    base = Pipeline.n

    def add(x: int) -> int:                       # a nested def
        return x + base

    Pipeline.n = add(10)


def doubled() -> None:
    twice: Callable[[int], int] = lambda x: x * 2  # a local
    total.set(sum(list(map(twice, xs()))))         # `map` calls one
    xs.set(sorted(xs(), key=twice))                # and so does `key=`

A closure captures by value, where it is made. base above is the number that was there at that moment. Python closes over the variable instead, so the two would part company in exactly two places, and both are refused by name: writing to a local a closure has already captured, and letting a closure that took a loop variable outlive its iteration. Inside the iteration it is fine, because both runs read the same value. A State cell or a store field read inside a closure is read when the closure runs, which is Python's behaviour too.

A view may not call a closure: building the screen only reads, and a closure may write. Call it from a handler and keep the answer in a State.

Arithmetic

Python's arithmetic operators work as-is. Besides +, -, *: / (the result is always float), // (floor toward negative infinity), % (the result takes the divisor's sign) and ** all compile to exactly Python's results.

q.set(1 / 3)          # 0.3333333333333333
d.set(-7 // 2)        # -4
r.set(7 % -2)         # -1
p.set(2 ** 10)        # 1024

Division by zero and overflow are exceptions in Python, and in the shipped app they surface the same way — the statement aborts, the app does not crash. Write int ** int exponents as non-negative literals (a negative exponent would change the result's type at runtime; with either side a float, negative exponents are fine). Do fallible / // % ** inside handlers and hand the view the result.

and, or, not work in conditions as-is. They also work as bool values (both.set(hot() and not cold())). Using and / or as a value on non-bools is refused (Python returns one of the operands themselves there, which is a different thing from a truth value).

Strings

Strings work as they do in Python: the methods, the length, indexing and slicing, in, and the conversions.

name.set(raw().strip().upper())
parts.set(raw().split(","))
name.set(", ".join(parts()))
first.set(raw()[0] + raw()[1:4])          # a code point, then a slice
n.set(len(raw()) + raw().find("a"))
if "ada" in raw().lower():
    tag.set("found")
n.set(int("42") + int(2.5) + round(2.5))  # round-half-to-even, as Python does

As with Python's arithmetic, the two runs use different code here — CPython's own method while you develop, a Rust twin written to answer exactly the same thing once compiled, failures included, so int("x") stops that statement in both — and the gate is what holds them together.

Format specs are Python's, in views and in handlers alike.

text(f"{total():,}")            # 1,234,567
text(f"{ratio():.1%}")          # 12.5%
text(f"{name():>10}")           # right-aligned in ten columns
text(f"{value():.2e}")          # 1.50e+00

Lists, charts, virtualized lists

Append to a list by concatenating and putting it back. The shipped app compiles this to a one-element append, so there is no copy cost.

items.set(items() + [x])     # append
items.set([])                # clear
len(items())                 # count

The rest of Python's list vocabulary works in handlers: in, slices, sorted / min / max / sum, comprehensions, enumerate and zip, a stepped range, and joining two lists. A local list carries its element type in the annotation, which is what the compiled side reads.

out: list[str] = []
for i, s in enumerate(items()):
    if s != "":
        out = out + [f"{i}: {s}"]
items.set(sorted(out))
best.set(max(scores()))

The operations that say nothing about the element — in, a slice, +, [::-1] — take a list of anything the app can hold, a value class or a tuple included. Comparing needs to know what to compare, so sorted, min and max take a key= — a lambda of one element, or the name of a helper that takes one — and sorted takes a reverse=. Sorting is stable either way: elements with equal keys stay in the order they came in.

by_score = sorted(players(), key=lambda p: p.score, reverse=True)
leader = max(players(), key=lambda p: p.score)
names = [p.name for p in players()]
newest = entries()[::-1]

reversed(xs) is Python's iterator, so it walks a list backwards in a for; where a list is wanted, xs[::-1] is the one that is a list in Python too.

Indexing reads an element, with Python's meaning: a negative index counts from the back, and an index past the end stops that statement in both runs.

first.set(names()[0])        # a state read, indexed
tail.set(names()[-1])        # last element (too short: the statement aborts)
for i in range(len(Cart.items)):
    Cart.items[i] = "-"      # `self.xs[i]` inside the store says the same

Charts draw lists of float or int.

values: State[list[float]] = State([])
line_chart(values(), height=120.0)
bar_chart(Metrics.svc_reqs, labels=Metrics.svc_names, height=100.0)
bar_chart(Books.profit, labels=Books.months, axis=True)          # negative months hang below the zero line
line_chart(series=Traffic.lines, colors=["accent", "#f38ba8"], axis=True)

The range spans the data and always contains zero, so a negative value hangs below the zero line; min= / max= pin it instead. axis=True adds tick labels and gridlines. Moving the pointer over a chart shows the value under it: the bar's or the sample's label (#3 when the chart has no labels) and one number per series. A script puts the pointer there with hover:<i> (the i-th point; hover@n:<i> for the n-th chart, bar and line charts counted together) and takes it away with hover:, and the dump then carries the readout — so what a person sees on hover is checked the way a click is. series= takes a list[list[float]] field for several lines or bar groups, colors= names one color per series, and color= colors a single series (demo/charts.py). progress(value) fills a track: width= / height= size it, label= captions it, and indeterminate=True sweeps a segment instead, for work with no known length.

Long lists go to list_view. It is virtualized: the row builder row(i) is called only for the visible range (a dozen or so calls even at 100k rows).

def row(i):
    return text(items()[i])

list_view(len(items()), row, item_height=22.0, height=200.0)
list_view(len(items()), row, item_height=22.0, grow=1.0)   # fill the parent's remaining height

selected= and on_select work on a list as they do on a table: the marked row is a number the app holds, and a click asks the app to move the mark rather than moving it behind the app's back. A script picks a row by what it says — the first text anywhere in that row, since a row is whatever the builder returned. A list with no on_select is an ordinary list, and a select: step walks past it.

list_view(len(names()), line, item_height=28.0, height=180.0,
          selected=picked(), on_select=picked.set, scroll_to=picked())

scroll_to= names a row to bring into view, and it is the one thing an app can say about scrolling: the position itself belongs to whoever is scrolling. The list obeys the number when it changes and leaves the viewport alone otherwise, so a list someone has scrolled is not dragged back every time the screen is rebuilt; -1 asks for nothing. There is no scrolling step in a script, for the same reason — what the dump carries, and what the two runs are held to, is the ask.

A table is a list_view with a header and column tracks. table(columns, count, row) calls row(i) for the visible rows only, and the builder returns a row of one cell per column; widths= are the tracks' shares. selected= tints a row and on_select receives the clicked row's index; sort= / descending= draw the header's arrow and on_sort receives the clicked column's index — the app re-sorts its own lists. In scripts, select:<first cell> picks a row and click:<column> sorts (demo/roster.py).

def cells(i: int):
    return row(text(Roster.names[i]), text(f"{Roster.scores[i]}"))

table(["member", "score"], len(Roster.names), cells, widths=[2.0, 1.0],
      selected=Roster.sel, on_select=Roster.pick,
      sort=Roster.sort_col, descending=Roster.desc, on_sort=Roster.sort_by, grow=1.0)

The row index is an int the row can use anywhere: in the text, in a condition, and in the row's own handlers.

def line(i):
    with row(spacing=6):
        text(f"{i + 1}. {items()[i]}")
        if i == Sel.idx:
            text("*")
        button("delete", on_click=lambda: Sel.drop(i))

list_view(len(items()), line, item_height=24.0, height=200.0)

The canvas

A canvas is a grid of virtual pixels you paint command by command. width and height count those pixels and scale says how many logical ones each of them takes, so canvas(160, 120, scale=4) occupies 640x480 on screen. The commands go in the block.

with canvas(160, 120, scale=4, background=0, palette=Game.palette):
    rect(Game.x, Game.y, 8, 8, 7)
    circle(30, 20, 4, 12)
    pixel_text(4, 4, f"SCORE {Game.score}", 7)

Every color is a number: the index of a color in palette, a list of hex colors the app declares. Numbering the colors is how tools for pixel art work, so drawing code written for one moves here with its numbers unchanged. An index past the end paints the last color, so an off-by-one is visible rather than invisible; a canvas with an empty palette paints magenta.

@store
class Game:
    palette: list[str] = ["#000000", "#2b335f", "#7e2072", "#19959c"]

The commands are pixel, line, rect, rect_outline, circle, circle_outline, triangle, triangle_outline, sprite and pixel_text. Coordinates are whole numbers — a pixel grid has no half pixels, so a float is refused and asks for int(...). sprite(x, y, source, u, v, w, h) copies a rectangle of a PNG onto the canvas; colkey= is the palette index that is not copied, and flip_x= / flip_y= mirror it. pixel_text writes in the canvas's own 4x6 font, on the pixel grid.

A for inside the canvas is the ordinary loop: what its body paints joins the frame where it stands.

with canvas(160, 120, scale=4, palette=Game.palette):
    for e in Game.enemies:
        sprite(e.x, e.y, "assets/sheet.png", 0, 16, 8, 8, colkey=0)

It walks a list the view can read directly — a State cell, a store field, a model's own field — whose elements are scalars or value classes, and for i, e in enumerate(...) binds the index beside the element. for i in range(2): works too, and is written out where it stands: the bounds are written-out numbers (up to 64 of them) because the loop becomes the elements it would have produced. The same loops work in any container, not only in a canvas.

A drawing command is not an element: it takes none of the shared properties, nothing in a canvas can be clicked, and a canvas is one image in the accessibility tree — an a11y_label= on it is the only way to say what it paints. What the dump prints is the frame itself, one command per line, so yokan gate compares what the two runs would have painted.

Canvas(160x120, scale=4, bg=#000000)[
  Rect(56, 100, 8, 8, #eeeeee)
  PixelText(4, 4, "SCORE 1250", #eeeeee)
]

Dicts

Read with .get, write per key, count with len, walk it like a Python dict. A key is any str the app can name — a literal, a state read, a loop variable.

prices["cherry"] = 200                 # per-key write
picked.set(prices().get("apple", -1))  # read: default when missing
if "cherry" in prices(): ...           # membership
len(prices())                          # count


def scan():
    for k in prices():                 # insertion order, as Python walks it
        last.set(k)
    for v in prices().values():        # the same order
        total.set(total() + v)
    for k in sorted(prices()):         # key order, when that is what you mean
        first.set(k)

A compiled dict remembers the order its keys went in, so a walk visits them in the order Python does. A dict also lives in a local, with its types written down: counts: dict[str, int] = {}, then counts[k] = counts.get(k, 0) + 1. A bare d[k] read raises KeyError when the key is missing, so it is written one of two ways: .get(key, default), which says what a missing key means, or inside a try, which catches the miss as Python does — what the try catches is the read itself, bound to a name:

try:
    gold = counts["gold"]
    found.set(f"gold {gold}")
except KeyError as e:
    found.set(f"no {e}")        # e reads 'gold', as in Python

.items() walks the pairs, in the same insertion order.

A dict of lists groups:

groups: State[dict[str, list[str]]] = State({})

for w in words():
    groups[w[0]] = groups().get(w[0], []) + [w]

Tuples

A tuple puts several values together as one, written and read the way Python writes one.

pair: State[tuple[str, int]] = State(("momo", 4))
rows: State[list[tuple[str, int]]] = State([])


def measure(word: str) -> tuple[str, int]:
    return (word.upper(), len(word))


def scan():
    label, n = measure("hello")          # unpacking
    first = pair()[0]                    # a part, by a literal position
    whole, rest = divmod(n, 3)
    for name, count in rows():           # a pair per row
        total.set(total() + count)
    for key, value in prices().items():  # and a dict walks as pairs
        seen.set(seen() + key)

The parts have types of their own, so a tuple is indexed by a literal position: a computed index would have no one type to be. Two parts or more, and the same shape can be a state, a field, a list's element, a parameter and a return.