Callback System

pgwidgets uses a callback model where the browser sends event messages to Python over WebSocket. You register handlers in Python; they fire when the user interacts with widgets in the browser.

Registering Callbacks

There are two ways to register a callback on any widget:

on() – handler receives only the callback arguments:

# Sync
btn.on("activated", lambda: print("clicked"))
entry.on("activated", lambda text: print(f"Entered: {text}"))

# Async
await btn.on("activated", on_click)

add_callback() – handler receives the widget as the first argument:

# Sync
btn.add_callback("activated", lambda widget: print(f"{widget} clicked"))

# Async
await btn.add_callback("activated", on_click)

Extra Arguments

Both on() and add_callback() accept extra positional and keyword arguments that are appended to every invocation:

def on_button(label, tag):
    label.set_text(f"Button {tag} clicked")

btn_a.on("activated", on_button, status_label, "A")
btn_b.on("activated", on_button, status_label, "B")

Callback Signatures

Different callbacks pass different arguments to the handler. Below are the common patterns:

Callback

Widgets

Handler receives

activated

Button

(nothing)

activated

CheckBox

(state: bool)

activated

TextEntry

(text: str)

activated

Slider, SpinBox, Dial

(value: number)

activated

ComboBox

(index: int)

activated

Dialog

(button_text: str)

page-switch

TabWidget, StackWidget, MDIWidget

(child: Widget, index: int)

page-close

TabWidget, StackWidget, MDIWidget

(child: Widget)

child-added

All containers

(child: Widget)

child-removed

All containers

(child: Widget)

selected

TreeView, TableView

(selected_items)

pointer-down

Image, Canvas

(event: dict)

drop-end

Image, Canvas, Label, …

(payload: dict)

expired

Timer

(nothing)

Async Callbacks

In the async API, callback handlers can be sync or async functions. Async handlers are automatically awaited:

async def on_click():
    await status.set_text("Clicked!")

await btn.on("activated", on_click)

File Transfers (Chunked Protocol)

When a user drags and drops files onto a widget (e.g., Image or Canvas with drop-end), the file data is transferred in chunks to avoid blocking the WebSocket with large payloads.

The protocol works as follows:

  1. The browser sends a callback message with transfer_id and file metadata (names, sizes, MIME types) but no file data.

  2. The framework fires a drop-start callback with the metadata so you can show progress UI.

  3. The browser sends binary-chunk JSON headers, each followed by a raw binary WebSocket frame carrying the chunk’s bytes (no base64 inflation).

  4. The framework fires drop-progress callbacks with transfer status.

  5. When all chunks arrive, the framework reassembles the bytes and fires the drop-end callback with the complete payload.

drop-start

Fires once at the beginning of a file transfer. The handler receives a dict with file metadata:

def on_drop_start(payload):
    files = payload["files"]  # list of {name, size, type}
    print(f"Receiving {len(files)} files...")

widget.on("drop-start", on_drop_start)

drop-progress

Fires after each chunk. The handler receives a dict:

def on_progress(info):
    pct = info["transferred_bytes"] / info["total_bytes"] * 100
    progress_bar.set_value(pct)
    if info["complete"]:
        print("Transfer complete!")

widget.on("drop-progress", on_progress)

The progress dict contains:

  • transfer_id – unique ID for this transfer

  • file_index – which file (0-based)

  • chunk_index – which chunk of the current file

  • num_chunks – total chunks for the current file

  • transferred_bytes – bytes received so far (all files)

  • total_bytes – total bytes expected (all files)

  • complete – True when all files are fully received

drop-end

Fires when all file data has been received. The handler receives the full payload; each file dict carries:

  • name (str) — original filename

  • size (int) — byte size

  • type (str) — MIME type from the browser (e.g. "image/png")

  • encoding (str) — wire format of data. Currently always "bytes" (data is raw bytes); reserved for "base64" if a future sender chooses to deliver the file body without binary reassembly.

  • data (bytes or None) — file contents. None on read error; in that case an additional error field carries the message.

def on_drop(payload):
    for f in payload["files"]:
        if f["encoding"] != "bytes":
            continue  # currently only "bytes" is emitted
        name = f["name"]
        size = f["size"]
        mime = f["type"]      # e.g. "image/png"
        content = f["data"]   # bytes
        print(f"Received {name}: {len(content)} bytes")

widget.on("drop-end", on_drop)

Example: File Drop Zone

drop_label = Widgets.Label("Drop files here")
drop_label.set_color("#e8f0fe", "#4a86c8")
textarea = Widgets.TextArea("")

def on_drop_start(payload):
    n = len(payload["files"])
    drop_label.set_text(f"Receiving {n} file(s)...")

def on_drop(payload):
    f = payload["files"][0]
    text = f["data"].decode("utf-8", errors="replace")
    textarea.set_text(text)
    drop_label.set_text(f"Loaded: {f['name']}")

drop_label.on("drop-start", on_drop_start)
drop_label.on("drop-end", on_drop)