Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples
Panes, state and events
On this page 9 sections
A kind is a type of pane: the problem pane, the code editor, the board. In Panelith a kind is a subclass of Pane. The class attributes describe how the shell presents it; the methods describe how it behaves.
A kind is a class#
class Notes(Pane):
kind = "notes"
persist = ["buffers"]
label = "Notes"
glyph = "document"
family = "author"
widget = "text"
subtabs = True
tip = "An editable text buffer with sub-tabs"
def initial_state(self, ctx):
return {"buffers": {"1": "# Notes\n\nType here. Edits are mirrored to Python."}}
The reference lists every attribute with its default. The ones that matter most:
| Attribute | Default | What it changes |
|---|---|---|
kind |
required | the id used by open_pane, Child, scope="kind:<id>" and the generated <kind>.new opener |
label, glyph, family, tip |
the kind title-cased, ring, independent, "" |
the tab and launcher tile: text, icon, colour, tooltip |
widget, widget_options |
markdown, {} |
which shell component renders the body, and its options |
subtabs |
False |
a sub-tab strip inside the pane (the Notes kind above) |
closable, launcher, single |
True, True, False |
a × on the tab; a launcher tile and opener; one pane at most |
header |
the app's pane_header |
which header controls this kind shows; PaneHeader(visible=False) drops the strip |
cycler |
None |
‹ › buttons that send a cycle event with {delta, list} |
children |
[] |
panes opened tethered to every new pane of this kind |
Registration is automatic: every concrete subclass is recorded when it is defined, and an App picks up the ones it lists in kinds plus any defined inside its own class body. Mark a base class abstract = True to keep it out, which is how the Boggle board ships two layouts from one Game class. Three small bases cover common shapes: EditorPane (sub-tabs on), OutputPane (the output widget, no launcher tile) and ListPane (a table bound to a ListSource named by source).
Every built-in glyph name is pictured in the Glyphs reference; Glyph(paths=[...]) draws one of your own.
State is the model#
Every open pane has a state dict. The widget renders it; the handlers change it. initial_state(ctx) returns the first one, and the server keeps a copy of every pane's state, per visitor, as the authority. When a handler returns set_state or patch_state, the server applies it to its own record before sending it on, so the next handler reads the current value. When a widget lets the user edit something directly (the text buffer, a form field with live on), the shell sends a state event and the server folds the payload into its copy. Either way, ctx.pane.state in a handler is what the user is looking at.
set_state(cid, state) replaces the whole dict; patch_state(cid, **fields) merges fields. Patch what changed: a pane that owns a large list and patches one counter sends one small action rather than the list.
The browser also persists the layout and each pane's state locally, so a reload restores the canvas. On reload the shell re-registers each pane with restore=True and its saved copy. If the server still has a record of that pane, the server's copy wins, because the server may have moved on. If it does not, because the server restarted or the visitor's session expired, the server adopts the browser's copy and carries on (Sessions has the details).
Events and handlers#
Widgets report interactions as named events with a JSON payload: a table row click is row with {"row": {...}}; a form submission is submit with {"values": {...}}; a trace across a grid is whatever the trace rules named, with {"path": [...]}. Buttons and chips in a blocks body emit the event they were given. Commands can send events too, which is how a hotkey reaches a pane.
A handler is a method decorated with the event name:
class Counter(Pane):
kind = "counter"
persist = ["items"] # a saved layout keeps the count
label = "Counter"
glyph = "bolt"
widget = "keyvalue"
tip = "A keyvalue pane with a bump event"
def initial_state(self, ctx):
return {"items": [{"key": "Count", "value": 0}]}
@event("bump")
def bump(self, ctx, payload):
n = ctx.pane.state["items"][0]["value"] + payload.get("by", 1)
return a.set_state(ctx.cid, {"items": [{"key": "Count", "value": n}]})
A handler returns one action, a list of actions, or None. Anything without an @event handler lands in on_event(ctx, name, payload). Handlers are inherited: a subclass gets its base's handlers and may override them.
Two more hooks: on_open(ctx) runs when a pane of the kind is created and may return extra actions; on_close(ctx) runs when it is closed. And view(state) derives what the widget shows from the model, the subject of Blocks.
The context object#
Every handler and command receives ctx, a small dataclass (reference):
| Attribute | Meaning |
|---|---|
ctx.app |
the running App: ctx.app.kind(id), ctx.app.list_source(id), ctx.app.panes |
ctx.pane |
the PaneRef of the pane the event came from: cid, kind, parent, state |
ctx.cid |
shorthand for ctx.pane.cid |
ctx.focused |
the focused tab as the shell reported it (cid, kind, parent) |
ctx.tabs |
every open tab in reading order |
ctx.tabs_of_kind(kind), ctx.children_of(cid, kind=None) |
filters over ctx.tabs |
ctx.state_of(cid) |
the server's copy of another pane's state |
ctx.args |
arguments a command was called with (a list row, a payload) |
ctx.viewport, ctx.narrow |
the window size the shell reported; narrow is true under 700px |
ctx.tabs and ctx.focused are what the shell sent with the request; ctx.state_of and ctx.app.panes are what the server knows. Together they let a handler find a related pane and update it, which is the pattern behind every multi-pane feature in these docs.
Actions#
Handlers and commands return actions from panelith.actions (reference):
| Action | Effect |
|---|---|
open_pane(kind, name=, state=, parent=, placement=, focus=, cid=, size=) |
open a pane; placement is focused (a tab in the focused pane), below, beside, new, or tab (a tab in the parent's own pane); cid= surfaces an existing pane instead |
set_state(cid, state), patch_state(cid, **fields) |
replace or merge a pane's state |
set_subtabs(cid, subtabs, active=) |
the sub-tab strip |
close_pane(cid), focus_pane(cid), rename_tab(cid, name) |
pane housekeeping |
set_title(text) |
the text in the top bar's centre |
notify(text, tone="info", ms=2600) |
a toast; tones info, ok, warn, error (the block tones bad, accent, dim map onto them) |
refresh_list(list_id) |
make the drawer reload a list |
open_command_bar(sigil, query) |
open the bar with text in it |
run_builtin(command_id) |
one of the shell's own commands, such as pane.maximize, or list:<id> to toggle a drawer |
send_event(cid, name, **payload) |
deliver an event to a pane, as if the widget had sent it |
spotlight(target, text), tour(*steps) |
ring a shell element with a caption, or walk through several |
set_route(path), open_url(url) |
put a path in the address bar's hash; open a URL |
open_pane with state= is how a command opens a pane with content already in it: the server registers the pane with that state and skips initial_state.
return a.spotlight("topbar.cmdbar", "The command bar")
Children and headerless panes#
A kind can declare panes that open alongside it, tethered to it. The child folds away when its parent's tab is not the active one and comes back with it; the chain mark on the divider shows the tether.
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
class Strip(Pane):
kind = "strip"
label = "Strip"
glyph = "list"
widget = "blocks"
launcher = False
header = PaneHeader(visible=False) # body only: no tab strip, no icons
def initial_state(self, ctx):
# Born tethered: start from the parent's current state.
return dict(ctx.state_of(ctx.pane.parent) or {}) if ctx.pane and ctx.pane.parent else {}
def view(self, state):
return b.view(
b.stats(b.stat(state.get("count", 0), "traces", tone="warn"), b.stat((state.get("last") or "—").upper(), "last"), size="sm"),
direction="row",
align="center",
)
placement is below or beside for a new pane taking size of the split, tab for a tab in the parent's own pane, or new for a new pane near the parent. focus=True makes the child the active tab. children_for(ctx) decides per client: the Board above opens its strip below itself on a desktop and as a tab on a phone, because ctx.narrow says so.
A headerless kind (PaneHeader(visible=False)) renders its body only. There is no tab strip, so it cannot be grabbed, renamed or collapsed by hand, which is what a child that should read as part of its parent wants.
Single-instance kinds#
class Clock(Pane):
kind = "clock"
label = "Clock"
glyph = "calendar"
widget = "keyvalue"
single = True # opening it again focuses the one that exists
tip = "A single-instance pane"
def initial_state(self, ctx):
return widgets.keyvalue({"Server time": time.strftime("%H:%M:%S"), "Opened": "once, however many times you ask"})
Press Show twice: the second time the existing pane is focused instead of a new one opening. single is for things there is only one of: a settings pane, a scoreboard, a home page.
Worked example: a form that writes to an output pane#
The Scorer is a form; the Scores pane below it is an output surface opened as its child. Submitting the form sends submit to the Scorer; its handler finds the child through the context and patches the child's state:
class Scorer(Pane):
kind = "scorer"
label = "Scorer"
glyph = "form"
family = "author"
widget = "form"
tip = "A form that writes into a tethered output pane"
children = [Child("scorer_out", placement="below", size=0.45)]
def initial_state(self, ctx):
return {
"title": "Score a word",
"fields": [
{"name": "word", "label": "Word", "placeholder": "panelith"},
{"name": "bonus", "label": "Bonus", "type": "select", "options": ["none", "double", "triple"], "value": "none"},
{"name": "shout", "label": "Shout it", "type": "checkbox", "value": False},
],
"submit_label": "Score",
}
@event("submit")
def submit(self, ctx, payload):
values = payload.get("values") or {}
word = (values.get("word") or "").strip()
if not word.isalpha():
return a.notify("Letters only, please", "warn")
mult = {"none": 1, "double": 2, "triple": 3}[values.get("bonus") or "none"]
points = len(word) * mult
shown = word.upper() if values.get("shout") else word
out = next(iter(ctx.children_of(ctx.cid, "scorer_out")), None)
if out is None:
return a.notify(f"{shown}: {points} points", "ok")
prev = (ctx.state_of(out.cid) or {}).get("stdout") or ""
line = f"{shown:<16} {len(word)} letters x{mult} = {points} points\n"
return a.patch_state(out.cid, status="done", stdout=prev + line, result={"word": word, "points": points}, title=shown)
class ScorerOut(Pane):
kind = "scorer_out"
label = "Scores"
glyph = "output"
family = "author"
widget = "output"
launcher = False
closable = False
def initial_state(self, ctx):
return {"status": "idle", "hint": "Submit the form above; each result is appended here"}
Three things happen on every submit, and all three are general machinery. The shell sent ctx.tabs, so children_of can find the output pane. The server's copy of the output pane's state gives the previous text to append to. And the returned patch_state names a different pane from the one the event came from, which is fine: actions are not scoped to the sender.
Lifecycle details#
Each visitor has their own set of panes on the server, keyed by the client-generated pane id. Two visitors can both have a pane called c1 and they will never meet. A kind's handler object is shared, however: there is one Scorer() instance for the whole process. Keep per-pane facts in the pane's state, not on self, unless you want them shared across every visitor, which is occasionally what you want (the Server pane keeps a push counter on the class on purpose).
If a server restarts and a browser sends an event for a pane the server has never seen, the server rebuilds the record from the tab list the shell sent and runs initial_state for it, so the pane keeps working rather than erroring.