Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples
Widgets
A kind names a widget, and the widget is a React component in the shell that receives the pane's state and renders it. Every widget follows the same contract: it reads a documented set of keys from the state dict, it reports interactions as events, and it never keeps anything the server would need to know. The whole of a pane's appearance is a JSON document Python can build, inspect and test. panelith.widgets has a builder per widget so the dict is a one-liner.
markdown#
Renders state["markdown"] (or state["text"]) as GitHub-flavoured markdown: tables, fenced code with syntax colour, [!NOTE] alerts, heading anchors. Fenced code gets a toolbar: Copy always; Show when the fence's info string carries demo=… (the pane then gets a code event with the id, the language, the attributes and the code); Open ↗ when it carries url=…. Relative links come back as a link event with the href; #anchor links scroll inside the pane; absolute URLs open in a new tab. state["anchor"] scrolls to a heading id (state["anchor_key"] forces a re-scroll). Raw HTML is escaped unless widget_options = {"allow_html": True}; {"highlight": False} skips the syntax colour.
A fence whose language is try is not code at all but a call to action: a card with an icon, a sentence or two of markdown, and one button, which sends the same code event as a Show (its label follows the demo kind, or button="…" sets it). Use it where the point is "press this", not "read this":
Written as:
```try demo=about button="Try one"
**A `try` card looks like this.** The body is markdown; the button opens the About pane beside this page.
```
class About(Pane):
kind = "about"
label = "About"
glyph = "info"
widget = "markdown"
tip = "A markdown pane whose text came from Python"
def initial_state(self, ctx):
return widgets.markdown("# Hello\n\nThis text came from **Python**.\n\n- Split the canvas with `alt+shift+\\`\n- Open the command bar with `alt+shift+.`")
These pages are this widget. The Show buttons are its code event, handled by the docs app.
html#
Renders state["html"] verbatim, or, with state["src"], an iframe pointing at that URL. The app author is trusted here: there is no sanitising. It emits nothing.
class Preview(Pane):
kind = "preview"
label = "Preview"
glyph = "image"
widget = "html"
tip = "The html widget: markup rendered as given"
def initial_state(self, ctx):
return {"html": "<div style='padding:1rem;font-family:system-ui'><b>Rendered</b> from Python, <i>verbatim</i>.</div>"}
text#
An editable plain-text buffer. With subtabs = True on the kind, the widget keeps one buffer per sub-tab under state["buffers"], keyed by sub-tab id; without sub-tabs it edits state["text"]. Every edit is mirrored to the server as a state event (debounced), so a handler elsewhere can read what the user typed through ctx.state_of(cid). widget_options = {"mono": True} sets a monospace face; placeholder comes from the options or the state.
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."}}
set_subtabs(cid, [("1", "Draft"), ("2", "Final")], active="2") names and selects sub-tabs from a handler; the strip's own add, close and rename report a subtabs event.
keyvalue#
A two-column list. state["items"] is a list of {"key", "value"} dicts (label and mono optional), or a plain object whose entries are shown in order; state["empty_text"] covers the empty case. It emits nothing, which makes it the natural target for server push: the Server pane is this widget with its items replaced every few seconds from a background thread.
class SystemInfo(Pane):
kind = "sysinfo"
label = "System"
glyph = "gear"
family = "assessment"
widget = "keyvalue"
tip = "Facts about the machine running Python"
def initial_state(self, ctx):
return widgets.keyvalue(
{
"Python": platform.python_version(),
"Platform": platform.platform(),
"Now": dt.datetime.now().isoformat(timespec="seconds"),
}
)
@event("refresh")
def refresh(self, ctx, payload):
return a.set_state(ctx.cid, self.initial_state(ctx))
table#
A sortable table. state["columns"] is a list of column specs, {"key", "label", "width", "grow", "mono"}, or plain strings; if it is missing the columns are inferred from the rows. state["rows"] is a list of dicts (a _tone key tints the row). Clicking a header sorts; clicking a row emits row with {"row": {...}}. widgets.table(rows, columns=...) infers columns from dicts, namedtuples, dataclasses or objects.
class Planets(Pane):
kind = "planets"
label = "Planets"
glyph = "table"
family = "assessment"
widget = "table"
tip = "A sortable table; a row click is an event"
def initial_state(self, ctx):
return widgets.table(PLANETS, columns={"planet": "Planet", "moons": "Moons", "au": "Distance (AU)"})
@event("row")
def row(self, ctx, payload):
row = payload.get("row") or {}
return a.notify(f"{row.get('planet')}: {row.get('moons')} moons", "info")
form#
Fields and a submit button. state["fields"] is a list of {"name", "label", "type", "options", "value", "placeholder", "rows", "help", "min", "max", "step"}; type is text (default), number, select (with options as strings or {"value", "label"}), textarea (with rows) or checkbox. state["title"], state["submit_label"] and state["message"] are optional. Submitting emits submit with {"values": {name: value}}. With state["live"] true, every change also emits change with {"name", "value"}. Fields added later by a patch_state get their defaults without clobbering what the user has typed; a changed state["reset"] (any value) discards the edits.
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)
output#
A run result: state["status"] is idle, running, done or error; state["stdout"] and state["stderr"] are shown in monospace; state["result"] is any JSON, pretty-printed; state["title"] labels the header and state["hint"] shows while idle. It emits nothing. OutputPane is a base class preset to this widget with no launcher tile, since an output pane is nearly always opened as a child of something. Appending is the caller's job: the Scorer reads the output pane's current stdout with ctx.state_of and patches it back with the new line added.
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"}
blocks#
A body composed from Python: a grid with a trace gesture, a message line, a toolbar of buttons, a countdown, big-number stats, cards with small figures, chips. The state is {"blocks": [...]} plus root layout keys, and the kind usually produces it from a view(state) method rather than storing it. Blocks is its own page.
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)
Custom widgets#
For a body none of the above can express, an app can ship its own widget as an ES module with no build step. Plugins shows one.
class Sketch(Pane):
kind = "sketch"
label = "Sketch"
glyph = "pen"
family = "author"
widget = "sketch" # registered by sketch.js, listed in App.plugins
tip = "A custom widget shipped as an ES module"
def initial_state(self, ctx):
return {"strokes": 0, "points": 0, "hint": "Draw with a finger or the mouse"}
@event("stroke")
def stroke(self, ctx, payload):
st = ctx.pane.state
return a.patch_state(ctx.cid, strokes=st.get("strokes", 0) + 1, points=st.get("points", 0) + int(payload.get("points", 0)))