PANELITH DOCS Open the interactive docs ↗

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

Custom widgets (plugins)

On this page 3 sections
  1. The Python side
  2. The module
  3. The SDK

For a body the built-in widgets and blocks cannot express, an app ships its own widget as an ES module. There is no build step: the app lists the file in plugins, the server serves it at /plugins/<name>, and the shell imports it before the first render and calls its default export with a small SDK.

The Python side#

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)))
class Docs(App):
    plugins = ["sketch.js"]      # relative to the file that defines the App

A plugin widget is a kind like any other: widget names what the module registered, the state is whatever the module reads, and the events it emits reach @event handlers.

The module#

// sketch.js — the plugin the docs' "Custom widgets" page demonstrates: a canvas
// you draw on; each finished stroke is one event to Python with its point count.
export default function install(pl) {
  const { h, React } = pl;
  pl.css(
    "sketch",
    ".sketch { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 6px; padding: 8px; }" +
      ".sketch canvas { flex: 1; min-height: 120px; width: 100%; border: 1px dashed var(--pl-pane-border); border-radius: 6px; background: var(--pl-pane-bg); touch-action: none; cursor: crosshair; }" +
      ".sketch__bar { color: var(--pl-text-dim); font-size: 12px; display: flex; justify-content: space-between; }",
  );
  pl.registerWidget("sketch", function Sketch({ state, emit }) {
    const ref = React.useRef(null);
    const pts = React.useRef([]);
    const drawing = React.useRef(false);
    function pos(e) {
      const r = ref.current.getBoundingClientRect();
      return { x: e.clientX - r.left, y: e.clientY - r.top };
    }
    function down(e) {
      drawing.current = true;
      pts.current = [pos(e)];
      ref.current.setPointerCapture(e.pointerId);
    }
    function move(e) {
      if (!drawing.current) return;
      const p = pos(e);
      const prev = pts.current[pts.current.length - 1];
      pts.current.push(p);
      const c = ref.current.getContext("2d");
      const r = ref.current.getBoundingClientRect();
      if (ref.current.width !== Math.round(r.width)) {
        ref.current.width = Math.round(r.width);
        ref.current.height = Math.round(r.height);
      }
      c.strokeStyle = getComputedStyle(ref.current).getPropertyValue("--pl-accent") || "#4f8cff";
      c.lineWidth = 2.5;
      c.lineCap = "round";
      c.beginPath();
      c.moveTo(prev.x, prev.y);
      c.lineTo(p.x, p.y);
      c.stroke();
    }
    function up() {
      if (!drawing.current) return;
      drawing.current = false;
      emit("stroke", { points: pts.current.length });
    }
    return h(
      "div",
      { className: "sketch" },
      h("canvas", { ref, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up }),
      h("div", { className: "sketch__bar" }, h("span", null, state?.hint ?? "Draw with a finger or the mouse"), h("span", null, `${state?.strokes ?? 0} strokes · ${state?.points ?? 0} points`)),
    );
  });
}

The SDK#

The default export receives pl, which is also parked on window.panelith:

Member For
React, h the shell's React and createElement; a plugin cannot import its own copy
registerWidget(id, Component) the component receives {cid, state, options, paneActive, emit, subtab} like every built-in widget
css(id, text) inject a stylesheet once; use the --pl-* tokens so both schemes work
Glyph the shell's glyph component, for icons that match the tabs
toast(text, tone, ms) a notice without a round trip
api.get(path), api.post(path, body) the shell's API client, with the right base under any prefix
syncClock(), now(), clockOffset the server clock, for anything that counts down to a server timestamp

Plugins load before the first render, so a kind can name a plugin widget from the start, and a module may register several widgets.

Note
The Boggle board was a plugin before the blocks widget existed. Plugins remain the escape hatch; when a body keeps coming up across apps, the better move is a new block type in the framework.