PANELITH DOCS Open the interactive docs ↗

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
  1. A kind is a class
  2. State is the model
  3. Events and handlers
  4. The context object
  5. Actions
  6. Children and headerless panes
  7. Single-instance kinds
  8. Worked example: a form that writes to an output pane
  9. Lifecycle details

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")
spotlight(\"topbar.cmdbar\", \"…\") rang this. Every shell surface has a target name; the reference lists them.

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.