PANELITH DOCS Open the interactive docs ↗

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

Widgets

On this page 9 sections
  1. markdown
  2. html
  3. text
  4. keyvalue
  5. table
  6. form
  7. output
  8. blocks
  9. Custom 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)))