Creating Panels

Panel Registration

Register panels through api.register_panel(...). The registration owns the user-facing title, mapping and dependency requirements, default layout and workspace-persistence policy.

api.register_panel(
    id="example",
    title="Example Panel",
    factory=create_panel,
    category="Examples",
    required_mappings=["record_id"],
    optional_mappings=["target_label"],
    state_version=1,
    persist_layout=True,
    persist_state=True,
)

Registration IDs are automatically namespaced by the plugin ID unless they are already fully qualified.

Panel Factory

A panel factory receives context and should accept **kwargs so the platform can pass lifecycle metadata without breaking older factories.

The platform may also provide values such as instance_id, restore_state and restore_metadata.

A normal factory should construct the controller and return a (view, controller) pair:

def create_panel(context, instance_id=None, **kwargs):
    controller = ExamplePanel(
        context=context,
        instance_id=instance_id,
    )
    return controller.panel(), controller

For panels with persist_state=True, PluginManager applies saved state to the returned controller after the factory completes. Do not normally restore the same state manually inside the factory.

Controller and View

The controller owns panel-specific lifecycle state such as:

  • event subscriptions;

  • Param watchers;

  • periodic callbacks;

  • panel-owned job handles;

  • small persistent UI state;

  • child controllers.

The returned view is the object placed in the workspace.

A simple static panel can return (view, None) when it owns no resources that require cleanup or persistence.

Panel Opening

The workspace-facing opener is context.plugins.open_panel(...).

Opening a panel first creates a temporary loading tile. When a JobManager is available, panel construction runs through a background panel-open job and the completed view is then installed into the workspace.

Each opening receives a separate workspace instance ID, so multiple instances of the same registered panel can coexist.

Caution

Panel construction should remain lightweight even though the current opener uses a background job. Do not start remote queries, large scans or expensive computations merely to construct the initial UI.

Mappings

Declare semantic requirements on the panel registration.

Required mappings gate construction of the real panel until they are resolved. Optional mappings do not block opening and should only enable additional behaviour when available.

api.register_panel(
    id="coordinates",
    title="Coordinate Inspector",
    factory=create_coordinates_panel,
    required_mappings=[
        "record_id",
        "coords.ra",
        "coords.dec",
    ],
    optional_mappings=["target_label"],
)

Do not construct a half-working domain panel and then duplicate the platform’s required-mapping flow inside the plugin.

Dependencies and Services

A panel can also declare required or optional Python packages and service keys:

api.register_panel(
    id="remote_viewer",
    title="Remote Viewer",
    factory=create_remote_viewer,
    uses_services=["astro.example.client"],
    requires=["astropy>=6"],
    optional_requires=["mocpy"],
)

Missing required packages prevent construction. Optional requirements can be used to enable additional behaviour without making the complete panel unavailable.

Event Subscriptions

Keep every subscription handle owned by the controller and provide ownership metadata for diagnostics.

subscription = context.events.subscribe(
    "selection.focus.changed",
    self._on_focus_changed,
    owner_id=self.instance_id,
    owner_label="Example Panel",
    owner_kind="panel",
)

self._subscriptions.append(subscription)

Event callbacks should normally use the event as a refresh signal and then read the current canonical state from context.selection, context.datasets or another platform service.

UI Thread

Panel and Bokeh objects should only be mutated from the document/UI thread once the view is live.

A workspace panel factory can currently be constructed in the panel-open worker before the view has been mounted. Avoid assuming pn.state.curdoc is a live document during construction.

If a controller needs a document-bound periodic callback or other live-session resource, attach it after the panel is mounted, for example in response to the matching plugin.panel.opened event.

Jobs submitted from normal UI callbacks can use JobManager on_done/on_error handlers; the manager schedules those callbacks onto the Panel document captured at submission time.

Do not mutate visible widgets from inside the worker function itself.

Stale Results

Asynchronous completion may arrive after the user changes dataset, focus, settings or request parameters.

Capture enough request identity to detect this, for example:

  • dataset ID;

  • row ID;

  • request generation;

  • service or query parameters.

Before replacing visible state, verify that the completion still belongs to the current request.

Cancellation helps reduce wasted work but is not a substitute for stale-result checks.

Persistence

A restorable controller can implement:

state_version = 2

def get_state(self):
    return {
        "selected_tab": self.tabs.active,
        "limit": int(self.limit.value),
    }

def restore_state(self, state):
    self.tabs.active = int(state.get("selected_tab", 0))
    self.limit.value = int(state.get("limit", 20))

snapshot_state() is also recognised as a state getter, and persistence_version is accepted as an alternative controller version attribute.

Persist only small JSON-safe UI state. Keep datasets, artifacts, clients and large arrays in the platform services designed for them.

The panel registration also provides:

  • state_version;

  • persist_layout;

  • persist_state;

  • restore_policy.

Setting persist_layout=False makes the workspace panel transient and excludes it from saved panel snapshots.

persist_state=False prevents PluginManager from applying saved controller state when the panel is reconstructed.

Disposal

dispose() must be idempotent and release every resource owned by the controller.

Typical cleanup includes:

  • unsubscribe event handles;

  • stop periodic callbacks;

  • remove Param watchers;

  • request cancellation of panel-owned jobs;

  • close panel-owned resources;

  • dispose child controllers.

def dispose(self):
    if self._disposed:
        return
    self._disposed = True

    for subscription in self._subscriptions:
        self.context.events.unsubscribe(subscription)
    self._subscriptions.clear()

    if self._job_handle is not None:
        self._job_handle.cancel()
        self._job_handle = None

When a workspace panel is removed, WorkspaceManager calls dispose() on the controller and, where separate, on a disposable view. Plugin disablement also closes the plugin’s open workspace panels.