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.