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
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:
adjacencyis 8 for a king's move or 4 for orthogonal only.- A cell counts as hit only inside the inner disc of radius
radius(a fraction of a cell), so a diagonal swipe cannot graze a neighbour. backtracklets moving back onto the previous cell undo the last one.- A path shorter than
min_cellsis dropped when the pointer lifts. - With
preview, the message line shows the traced text as it grows, with amin_text+ hint while it is too short. - A trace consumes the message the grid was showing. After the pointer lifts the line holds a small spinner until the reply replaces it, so the previous verdict never flashes back between two words.
- With
live_event, the grid also posts the path each time it changes while the finger is down, one request in flight at a time. The handler can answer withpending={"path": [...], "message": ...}on the grid; if that is the path that lifts, the verdict appears at once instead of after a round trip. - With
judge, the browser gives the verdict itself the moment the finger lifts, with no round trip at all. The server sendsfingerprint(salt, text)of every accepted text (a salted hash, so the solution does not travel as a word list), the texts already taken, the minimum length, a points table by length, and a message template and tone per outcome:ok,known,short,miss. The server still judges the submitted path; its reply carries the samekey, so the two render as one message.
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.