PANELITH DOCS Open the interactive docs ↗

Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples

Blocks: a pane body composed in Python

On this page 7 sections
  1. The view hook
  2. The block types
  3. The trace gesture
  4. Countdowns
  5. Tones, tags and layout
  6. Worked example: Super Boggle
  7. What it cost and what it bought

The generic widgets cover a lot: a markdown page, a table, a form, an output surface. They do not cover a game board. The first Boggle example shipped a 329-line JavaScript plugin for the board: a grid of letters, a finger or mouse dragged across them, a countdown, the status line, the buttons. Everything else was Python.

The question was whether the JavaScript had to exist. Measured on localhost, a round trip from a pane event to its handler's response takes under half a millisecond, so latency was not the reason. The reason was that a drag across a grid produces dozens of pointer positions per second, and sending each one to Python to decide whether it extends the path would make the game unplayable across the internet.

The blocks widget is the resolution. The pane body is a list of blocks described in Python, and the few things that genuinely must be decided between frames, which cells a drag has covered and when a clock reaches zero, are decided in the browser by rules Python supplies. One event per finished gesture reaches Python.

The view hook#

A kind with widget = "blocks" usually does not store its block list in the state. It stores a model and derives the blocks from it:

class Tally(Pane):
    kind = "tally"
    label = "Tally"
    glyph = "star"
    widget = "blocks"
    tip = "A blocks pane: the view is derived from the model"

    def initial_state(self, ctx):
        return {"n": 0}

    def view(self, state):
        return b.view(
            b.stats(b.stat(state["n"], "count", tone="warn")),
            b.toolbar(b.button("Add one", "bump", payload={"by": 1}, primary=True), b.button("Reset", "reset")),
            align="center",
        )

    @event("bump")
    def bump(self, ctx, payload):
        return a.patch_state(ctx.cid, n=ctx.pane.state["n"] + payload["by"])

    @event("reset")
    def reset(self, ctx, payload):
        return a.patch_state(ctx.cid, n=0)

view(state) runs on the server every time the state is set or patched, including at registration and when a browser restores a pane, and whatever it returns is merged over the state on its way to the shell. The handlers only ever update the model. There is no way for the display to drift from the state, because the display is a function of the state and nothing else.

Every constructor in panelith.blocks returns a plain dict, so a view is JSON and a test can assert on it directly.

The block types#

The reference has every constructor with its arguments. In short:

Constructor Renders
view(*blocks, direction, scroll, align, tags) the root; direction is column or row, scroll is x or y, align is center or start
grid(cells, columns, trace=, disabled=, message=, pending=, grow=, height=) a grid of cells, each a string or {"text", "tone"}; grows to fill the pane, or height in pixels
trace_rules(event=, adjacency=, min_cells=, backtrack=, radius=, preview=, min_text=, live_event=, judge=) how a drag across a grid behaves (below)
judge(salt=, hashes=, known=, min_text=, points=, say=, tone=, key=) and fingerprint(salt, text) what the browser needs to give a trace its verdict itself (below)
message(text, hint, tone=, key=) a one-line status; a new key replays the tone's animation
text(value, tone=, size=) a paragraph; size is sm, lg or xl
toolbar(*items, justify=) and button(label, event, payload=, primary=, disabled=) a row of buttons and inline items
countdown(until, event=, low=, label=, tone=) m:ss until the server timestamp until; fires event once at zero
stats(*items, size=, justify=) and stat(value, label, tone=) big numbers with captions; items may also be countdowns
cards(*items, wrap=, figure_px=, empty_text=, justify=, grow=, scroll=) and card(title, subtitle=, figure=, tone=, event=, payload=) small cards; a card with an event is a button
figure(cells, columns, path, px=) a miniature grid with a path drawn over it
chips(*items, title=, wrap=, grid=, grow=, scroll=) and chip(value, tone=, event=, payload=) small labels, clickable when given an event

Any block accepts key for stable identity; grow makes a block take the spare room; scroll lets a block scroll on its own. None entries in any item list are dropped, so a view can write None if over else b.button(...) and stay readable.

class Playground(Pane):
    kind = "playground"
    label = "Playground"
    glyph = "grid"
    family = "author"
    widget = "blocks"
    tip = "Every block type, wired to events you can see"

    def initial_state(self, ctx):
        return {"traced": None, "clicks": 0, "tone": "accent", "until": time.time() + 60}

    def view(self, state):
        traced = state.get("traced")
        msg = b.message(traced.upper(), "traced across the grid", tone="accent", key=traced) if traced else b.message(hint="Drag across the letters; the path comes back as one event")
        return b.view(
            b.grid(PLAY_CELLS, 4, trace=b.trace_rules(event="trace", adjacency=8, min_cells=2), message=msg, height=240),
            b.stats(
                b.stat(state.get("clicks", 0), "clicks", tone="warn"),
                b.stat(len(traced or ""), "letters"),
                b.countdown(state["until"], event="restart", label="restart", low=10),
                size="sm",
            ),
            b.toolbar(*[b.button(t, "toast", payload={"tone": t}, primary=(t == state.get("tone"))) for t in TONES]),
            b.chips(*[b.chip(t.upper(), tone=t, event="toast", payload={"tone": t}) for t in TONES], title="chips fire events too", wrap=True),
            scroll="y",
        )

    @event("trace")
    def trace(self, ctx, payload):
        path = payload.get("path") or []
        return a.patch_state(ctx.cid, traced="".join(PLAY_CELLS[i] for i in path if 0 <= i < len(PLAY_CELLS)).lower())

    @event("toast")
    def toast(self, ctx, payload):
        tone = payload.get("tone", "info")
        return [a.patch_state(ctx.cid, clicks=ctx.pane.state.get("clicks", 0) + 1, tone=tone), a.notify(f"A {tone} toast from Python", tone)]

    @event("restart")
    def restart(self, ctx, payload):
        return [a.patch_state(ctx.cid, until=time.time() + 60), a.notify("The countdown fired an event and Python reset it", "info")]

The trace gesture#

A grid with trace rules lets the user drag a path across it. The rules are enforced in the browser, between frames:

When the pointer lifts, the grid emits trace_rules(event=...) with {"path": [cell indexes]}. Python validates the path again before trusting it, because the browser is not a trusted party.

class Board(Pane):
    kind = "board"
    label = "Board"
    glyph = "grid"
    widget = "blocks"
    tip = "A grid with a trace gesture and a headerless child below"
    children = [Child("strip", placement="below", size=0.25)]

    def children_for(self, ctx):
        # A phone has no room for a split: the strip becomes a second tab.
        return [Child("strip", placement="tab")] if ctx.narrow else self.children

    def initial_state(self, ctx):
        return {"last": None, "count": 0}

    def view(self, state):
        last = state.get("last")
        msg = b.message(last.upper(), "traced", tone="ok", key=last + str(state["count"])) if last else b.message(hint="Drag across three or more letters")
        return b.view(b.grid(BOARD, 3, trace=b.trace_rules(event="trace", adjacency=8, min_cells=3), message=msg, height=200))

    @event("trace")
    def trace(self, ctx, payload):
        word = "".join(BOARD[i] for i in payload.get("path") or []).lower()
        patch = {"last": word, "count": ctx.pane.state.get("count", 0) + 1}
        out = [a.patch_state(ctx.cid, **patch)]
        for child in ctx.children_of(ctx.cid, "strip"):
            out.append(a.patch_state(child.cid, **patch))
        return out

Countdowns#

A countdown ticks in the browser against a server timestamp. The shell asks the server for its clock once (GET /api/time) and keeps the offset, so a visitor whose system clock is wrong still sees the right remaining time and the clock cannot be gamed by changing it. Below low seconds it turns red. With event, the countdown fires that event exactly once when it reaches zero, and the Python handler decides what that means. The Playground resets its own clock on restart; the Boggle board ends the round on timeup, and its handlers compare time.time() against the stored deadline before accepting a word, so a late submission is refused server-side regardless of what the browser showed.

Tones, tags and layout#

A tone is a named colour: ok, warn, bad, accent, dim. Messages, stats, text, chips, cards and buttons accept one. An ok message pops and a bad one shakes, and giving the message a new key replays the animation.

tags on the root become tag-<name> classes on the pane body. They are for styling and for tests: the Boggle board adds over when the round ends, and its headless test waits for .widget-blocks.tag-over.

The root's direction, scroll and align arrange the blocks. A column that scrolls (scroll="y") is the general-purpose page; a row with centred alignment and a horizontally scrolling cards block is a score strip.

Worked example: Super Boggle#

The full game lives in examples/boggle/ in the repository and runs inside the Tour: a 5×5 board with trace rules, a live peek event that prepares the verdict while the finger is still down, a browser-side judge with fingerprinted solutions, a countdown that ends the round, and a score pane that is one class rendered as a thin headerless strip or a full tab from the same view code. It is 394 lines of Python and no JavaScript; the plugin it replaced was 329 lines of JavaScript on top of the Python.

class Game(Pane):
    kind = "boggle"
    widget = "blocks"

    def view(self, state):
        if state.get("over"):
            return self.view_over(state)
        last = state.get("last")
        msg = b.message(last["msg"], tone=TONES.get(last["status"]), key=state["seq"]) if last else b.message(hint="Drag across letters to spell a word")
        return b.view(
            b.grid(state["board"], state["size"],
                   trace=b.trace_rules(event="submit", adjacency=8, min_cells=2, backtrack=True,
                                       preview=True, min_text=state["min_len"], live_event="peek",
                                       judge=self.judge_for(state)),
                   message=msg, pending=state.get("pending")),
            b.toolbar(
                b.button("New game", "new", primary=True),
                b.button("End round", "end"),
                b.countdown(state["ends_at"], event="timeup", low=15),
            ),
        )

fan_out returns a patch_state for the board and one for every score pane tethered to it, found through ctx.children_of. Both panes hold the same model; each derives its own view from it.

What it cost and what it bought#

The blocks widget, its grid component and their stylesheet added about 500 lines to the shell, once. What was given up is generality: blocks are a vocabulary, and a body outside it needs a plugin widget. What was gained is that the vocabulary is enough for the things dashboards keep needing, that every pane built from it is testable as plain data, and that the gesture logic was written once and reviewed once rather than per app.