Authoring interactive tools¶
This section is aimed at experienced users who are already comfortable with Qt,
xarray, and ERLabPy’s analysis model, and want to contribute a new interactive tool to
erlab.interactive.
It focuses on ERLab-specific integration points: the ToolWindow class, manager
support, updates when ImageTool data changes, public launch paths, and the test/docs
work expected in a contribution. For general repository conventions, see the contributing
guide.
For the user-facing workflow, start with the manager guide sections on rows kept with the window that made them, result placement, updating rows marked Stale, and code for repeating steps. This page explains how tool authors make those features work.
Start with the right shape¶
Most user-facing ERLabPy GUIs should inherit from
erlab.interactive.utils.ToolWindow for it to correctly integrate with the
ImageTool manager.
In practice, ToolWindow enables several things:
save/restore support through
to_dataset(),from_dataset(),to_file(), andfrom_file(), usingtool_data,StateModel,tool_status, and optional save-only payload hooks for persisted data that should stay out of undo/redo history;integration with the ImageTool manager, including tool naming, preview images, rich info text, and manager refresh notifications through
sigInfoChanged;remembering which ImageTool data and selection opened the tool, including saved metadata, stale or unavailable status tracking, and the built-in update dialog;
ImageTool windows declared in
IMAGE_TOOL_OUTPUTSthat appear as child rows of the tool in the ImageTool manager and can be reopened, refreshed, and persisted; andthe update hooks used by tools that can react when ImageTool data changes, such as
validate_update_data(),update_data(), and_cancel_background_work().
Use ToolWindow when your tool should do any of the following:
accept an
xarray.DataArrayas its main input;serialize and restore itself through
to_dataset()/from_dataset();appear as a child row of an ImageTool in the ImageTool manager; or
refresh itself when that ImageTool’s data changes.
ToolWindow assumes a few things about your implementation:
The constructor accepts
dataas its single positional input. Additional options can be keyword arguments.The nested
StateModelcontains the lightweight UI state that participates in undo/redo history.tool_datareturns the mainDataArray.tool_statusserializes and reapplies the live widget state.If the tool needs extra persisted state that should not participate in history, override
_append_persistence_payload()and_restore_persistence_payload().
As a practical authoring checklist:
Required for the core
ToolWindowinterface: constructor withdata,StateModel,tool_data, andtool_status.Required if the tool can be refreshed from ImageTool data:
update_data(). In practice, this is the normal baseline for repository tools, so the examples below implement it even in the minimal case. ReturnFalsewhen the new data is accepted but the tool cannot publish a fresh result yet.Optional, but strongly recommended for user-facing tools:
tool_name(the base class default is just"tool").Optional for tools with expensive or bulky save-only state:
_append_persistence_payload()and_restore_persistence_payload().Optional manager / provenance integration:
validate_update_data(),_cancel_background_work(),preview_imageitem,info_text,COPY_PROVENANCE,IMAGE_TOOL_OUTPUTS, anddetached_output_imagetool_provenance().
If your tool is a quick internal prototype or does not need save/restore support, a
plain Qt widget may be enough. For anything that should behave like dtool, ftool,
goldtool, or ktool, start from ToolWindow.
Map capabilities to hooks¶
When you add a new tool, think in terms of user-visible capabilities first and then wire
up the corresponding ToolWindow surface:
Save and restore the tool window: required; implement
StateModel,tool_status, andtool_data. If save/load also needs large arrays calculated by the tool, keep them out oftool_statusand use_append_persistence_payload()/_restore_persistence_payload()instead.Show rich metadata in the ImageTool manager: optional; implement
info_text,preview_imageitem, and emitsigInfoChangedwhen either changes.Refresh the tool when the ImageTool that opened it changes:
update_data()is part of the minimal tool surface;validate_update_data()and_cancel_background_work()are optional additions when normalization or worker shutdown matter.Generate code that repeats the tool’s main action: optional; usually set
COPY_PROVENANCEto aToolScriptProvenanceDefinition. Preferlabel + expression_method + assignfor the common single-step case, addprelude_methodwhen the generated code needs setup statements before the final expression, and useoperations_methodonly when the copied script truly needs multiple labeled operations.Expose ImageTool windows as child rows of the tool and let the manager update them later: optional; declare a stable output id, preferably with
enum.StrEnum, and add it toIMAGE_TOOL_OUTPUTSwith aToolImageOutputDefinition(data_method=..., provenance=...).data_methodshould name a zero-argument instance method that returns the current outputDataArray. The string output id is what the manager persists in saved workspaces, so use a tool-qualified name such as"mytool.filtered".Open an ImageTool that is not one of the declared outputs: optional; call
_launch_detached_output_imagetool(...). In the manager, that opens a fresh independent top-level ImageTool window with no saved parent row or output id. Outside the manager, each call opens a new standalone ImageTool window. Overridedetached_output_imagetool_provenance()only when standalone launches should show different generated code fromcurrent_provenance_spec(), and keep that hook free of blocking side effects such as modal warnings.
The important distinction is that outputs declared in IMAGE_TOOL_OUTPUTS become
ImageTool windows as child rows of the tool in the manager, keyed by a serialized
output_id. Any ImageTool opened without an output_id is not reproducible as one of
those windows. Do not use _launch_detached_output_imagetool(...) as a substitute for
a real declared ImageTool output.
In user-facing terms, this is the difference between an ImageTool window kept with the tool that made it and a detached top-level window; see Choosing where new data opens.
A real example is Fit2DTool:
Fit2DTool.Output.PARAMETER_VALUESandFit2DTool.Output.PARAMETER_STDERRare declared inIMAGE_TOOL_OUTPUTS, so those parameter plots become ImageTool windows as child rows of the fit tool, with persistedoutput_ids.Fit2DTool._show_dataarray_in_itool()also has a generic path for arbitraryDataArrays that are not declared outputs. In the manager, that path opens a fresh independent top-level ImageTool window each time. Outside the manager, it opens a fresh standalone ImageTool window each time.That generic path is intentionally not a declared ImageTool output because the manager cannot recreate it from either
source_specoroutput_id.
Build two concrete examples¶
Create the runtime module in src/erlab/interactive/ and keep any .ui file (if you
use Qt Designer) next to it. The rest of this page uses two real examples:
a minimal tool that only implements the required
ToolWindowsurface; anda fuller tool that also opts into manager metadata, copy-code support, and an ImageTool window kept with the tool in the manager.
Minimal example: only the required ToolWindow methods¶
If you only want to remember the minimum required pieces, this is it. The tool below is still
fully functional: it displays a scaled 2D array, saves and restores its state, and can
accept replacement data. It intentionally does not implement any of the optional
manager integration or provenance hooks, but it still includes update_data() because
that is the practical baseline for tools that may be launched from ImageTool.
import pydantic
import pyqtgraph as pg
import xarray as xr
from qtpy import QtWidgets
import erlab
class MinimalScaleTool(erlab.interactive.utils.ToolWindow):
tool_name = "scaletool" # In practice, always set a stable user-facing tool name.
class StateModel(pydantic.BaseModel):
data_name: str
scale: float = 1.0
def __init__(self, data: xr.DataArray, *, data_name: str | None = None) -> None:
super().__init__()
# Store the main array and a stable variable name for saved state / reloads.
self._data = self._coerce_data(data)
self._data_name = data_name or (self._data.name or "data")
# Build a normal central widget. ToolWindow wraps it in its own root widget.
root = QtWidgets.QWidget(self)
layout = QtWidgets.QVBoxLayout(root)
self.setCentralWidget(root)
self.plot = pg.PlotWidget()
self.image = erlab.interactive.utils.xImageItem(axisOrder="row-major")
self.scale_spin = QtWidgets.QDoubleSpinBox()
self.scale_spin.setRange(0.1, 100.0)
self.scale_spin.setValue(1.0)
self.scale_spin.valueChanged.connect(self._refresh)
self.plot.addItem(self.image)
layout.addWidget(self.plot)
layout.addWidget(self.scale_spin)
# Paint the first frame after all widgets exist.
self._refresh()
def _coerce_data(self, data: xr.DataArray) -> xr.DataArray:
# Minimal tools can validate inline instead of overriding validate_update_data().
parsed = erlab.interactive.utils.parse_data(data)
if parsed.ndim != 2:
raise ValueError("`data` must be 2D")
return parsed
@property
def tool_data(self) -> xr.DataArray:
# ToolWindow stores this array separately from the UI state model.
return self._data
@property
def tool_status(self) -> StateModel:
# The getter must describe the current UI state.
return self.StateModel(
data_name=self._data_name,
scale=float(self.scale_spin.value()),
)
@tool_status.setter
def tool_status(self, status: StateModel) -> None:
# The setter must fully restore the state captured by the getter.
self._data_name = status.data_name
self.scale_spin.setValue(status.scale)
self._refresh()
def update_data(self, new_data: xr.DataArray) -> bool:
# This is the minimal refresh path: replace the data and repaint.
self._data = self._coerce_data(new_data)
self._refresh()
return True
def _display_data(self) -> xr.DataArray:
return (self.tool_data * float(self.scale_spin.value())).rename(self._data_name)
def _refresh(self) -> None:
self.image.setDataArray(self._display_data())
That is the minimum ToolWindow surface to keep in your head:
constructor with
dataas the single positional argument;nested
StateModel;tool_data;tool_statusgetter and setter; andupdate_data.
Everything below is optional integration that you add when the tool needs it.
Full example: a tool that works well inside the manager¶
The next example uses the same core ToolWindow interface, but it also implements the
optional pieces that make a tool feel fully integrated with ERLabPy: manager preview
metadata, copy-code provenance, input validation, and an ImageTool window kept with the
tool row.
import enum
import typing
import pydantic
import pyqtgraph as pg
import xarray as xr
from qtpy import QtCore, QtWidgets
import erlab
class MyTool(erlab.interactive.utils.ToolWindow):
tool_name = "mytool"
class Output(enum.StrEnum):
# Stable, serialized ids are what the manager stores in workspaces.
FILTERED = "mytool.filtered"
# Optional: describe the main "Copy Code" action declaratively.
COPY_PROVENANCE: typing.ClassVar = (
erlab.interactive.utils.ToolScriptProvenanceDefinition(
start_label="Start from current mytool input data",
label="Apply the current moving-average filter",
expression_method="_filter_expression",
assign="result",
)
)
# Optional: declare an ImageTool window that appears under this tool in the manager.
IMAGE_TOOL_OUTPUTS: typing.ClassVar = {
Output.FILTERED: erlab.interactive.utils.ToolImageOutputDefinition(
data_method="_filtered_output",
provenance=erlab.interactive.utils.ToolScriptProvenanceDefinition(
start_label="Start from current mytool input data",
label="Apply the current moving-average filter",
expression_method="_filter_expression",
assign="filtered",
),
)
}
class StateModel(pydantic.BaseModel):
data_name: str
sigma: float = 1.0
show_reference: bool = False
def __init__(self, data: xr.DataArray, *, data_name: str | None = None) -> None:
super().__init__()
# Validate the input once up front and keep a stable variable name around.
self._data = self.validate_update_data(data)
self._data_name = data_name or (self._data.name or "data")
self._filtered_itool: QtWidgets.QWidget | None = None
root = QtWidgets.QWidget(self)
layout = QtWidgets.QVBoxLayout(root)
controls = QtWidgets.QHBoxLayout()
self.setCentralWidget(root)
# This example shows two image layers: the filtered output and the reference.
self.plot = pg.PlotWidget()
self.filtered_image = erlab.interactive.utils.xImageItem(axisOrder="row-major")
self.reference_image = erlab.interactive.utils.xImageItem(
axisOrder="row-major"
)
self.sigma_spin = QtWidgets.QDoubleSpinBox()
self.reference_check = QtWidgets.QCheckBox("Show reference")
self.copy_btn = QtWidgets.QPushButton("Copy Code")
self.open_filtered_btn = QtWidgets.QPushButton("Open filtered output")
self.sigma_spin.setRange(0.0, 100.0)
self.sigma_spin.setValue(1.0)
self.sigma_spin.valueChanged.connect(self._refresh)
self.reference_check.toggled.connect(self._refresh)
# COPY_PROVENANCE only defines the generated code. A UI button still has to
# connect to the built-in copy_code() slot explicitly.
self.copy_btn.clicked.connect(self.copy_code)
self.open_filtered_btn.clicked.connect(self.open_filtered)
self.reference_image.setOpacity(0.35)
self.plot.addItem(self.filtered_image)
self.plot.addItem(self.reference_image)
layout.addWidget(self.plot)
controls.addWidget(self.sigma_spin)
controls.addWidget(self.reference_check)
controls.addWidget(self.copy_btn)
controls.addWidget(self.open_filtered_btn)
layout.addLayout(controls)
self._refresh(notify=False)
@property
def preview_imageitem(self) -> pg.ImageItem:
# Optional: this is the thumbnail the manager shows for the tool.
return self.filtered_image
@property
def info_text(self) -> str:
# Optional: short HTML summary shown in the manager side panel.
sigma = float(self.sigma_spin.value())
shape = " x ".join(str(size) for size in self.tool_data.shape)
return (
f"<b>{self.tool_name}</b><br>"
f"shape: {shape}<br>"
f"window: {self._filter_window()}<br>"
f"show reference: {self.reference_check.isChecked()}<br>"
f"sigma spin value: {sigma:g}"
)
@property
def tool_data(self) -> xr.DataArray:
return self._data
@property
def tool_status(self) -> StateModel:
return self.StateModel(
data_name=self._data_name,
sigma=float(self.sigma_spin.value()),
show_reference=self.reference_check.isChecked(),
)
@tool_status.setter
def tool_status(self, status: StateModel) -> None:
self._data_name = status.data_name
self.sigma_spin.setValue(status.sigma)
self.reference_check.setChecked(status.show_reference)
self._refresh(notify=False)
def validate_update_data(self, new_data: xr.DataArray) -> xr.DataArray:
# Optional but recommended: normalize / reject data updates in one place.
data = erlab.interactive.utils.parse_data(new_data)
if data.ndim != 2:
raise ValueError("`data` must be 2D")
return data
def update_data(self, new_data: xr.DataArray) -> bool:
# Preserve the existing UI state while swapping in replacement data.
status = self.tool_status
self._data = self.validate_update_data(new_data)
self.tool_status = status
self._notify_data_changed()
return True
def _filter_window(self) -> int:
return max(1, int(round(self.sigma_spin.value())))
def _filtered_output(self) -> xr.DataArray:
# Optional output method used by IMAGE_TOOL_OUTPUTS.
window = self._filter_window()
filtered = self.tool_data.rolling(
{dim: window for dim in self.tool_data.dims},
center=True,
min_periods=1,
).mean()
return filtered.rename(f"{self._data_name}_filtered")
def _filter_expression(
self,
*,
input_name: str | None = None,
data: xr.DataArray | None = None,
) -> str:
# Optional provenance method: return the final expression only.
del data
input_expr = input_name or "data"
window = self._filter_window()
rolling_kwargs = ", ".join(
f"{dim}={window}" for dim in self.tool_data.dims
)
return (
f"{input_expr}.rolling("
f"{rolling_kwargs}, center=True, min_periods=1"
").mean()"
)
@QtCore.Slot()
def open_filtered(self) -> None:
# Declaring IMAGE_TOOL_OUTPUTS is not enough by itself. The tool still needs
# an action that opens the ImageTool through _launch_output_imagetool().
tool = self._launch_output_imagetool(
self._filtered_output(),
output_id=self.Output.FILTERED,
)
if tool is not None:
self._filtered_itool = tool
def _refresh(self, *, notify: bool = True) -> None:
# Keep the on-screen view and any manager-facing outputs in sync.
self.filtered_image.setDataArray(self._filtered_output())
self.reference_image.setDataArray(self.tool_data, update_labels=False)
self.reference_image.setVisible(self.reference_check.isChecked())
if notify:
self._notify_data_changed()
Some implementation details matter:
Call
super().__init__()before creating your UI.ToolWindowinstalls the manager status banner and keyboard shortcuts.Always use
self.setCentralWidget(...), notQtWidgets.QMainWindow.setCentralWidget.ToolWindowwraps the actual content widget so it can show update status above it.Keep
StateModelfocused on UI state. The main data already comes fromtool_dataand is stored separately in workspace files. If you need to persist expensive calculated arrays, use the explicit persistence hooks instead oftool_statusso ordinary history snapshots stay cheap.Make the
tool_statusgetter and setter fully describe and restore the current UI state. A restored tool should look the same as one configured interactively.Keep provenance and output declarations declarative. Prefer method names in
ToolScriptProvenanceDefinitionandToolImageOutputDefinitionover inline lambdas so the class body remains readable and testable.If you want a visible “Copy Code” button, create that button in the UI and connect it to
self.copy_code. DeclaringCOPY_PROVENANCEonly tellsToolWindowhow to generate the code when that slot is called.Keep provenance methods on the shared
ToolWindowcalling convention:(*, input_name: str | None = None, data: xr.DataArray | None = None). Most single-step methods should return only the unassigned final expression. LetToolScriptProvenanceDefinition(assign=...)orassign_method=...define the final variable name, and useprelude_methodonly when the generated code needs setup statements before that final expression. Output-specific helpers can still inspectdatawhen the generated code depends on the current output array.Prefer
expression_methodfor ordinary single-step generated code. Useoperations_methodonly when the generated code needs multiple labeled steps, and reach forseed_code,seed_code_method, or explicitactive_nameonly for the rarer cases where the simpler expression-plus-assignment path cannot describe the tool cleanly.If generated code should be unavailable for the current state, return
Nonefrom a dynamic provenance method such aslabel_method,assign_method, orprelude_methodrather than returning partial code.If
_refresh()changes manager-visible data, previews, or ImageTool windows opened from the tool, call_notify_data_changed()from that path rather than emitting raw signals manually.If you want an ImageTool window to appear as a child row of the tool in the manager, do both pieces: declare it in
IMAGE_TOOL_OUTPUTSand open it through_launch_output_imagetool(..., output_id=...). Declaring the output alone does not create any user-facing action.
DerivativeTool in erlab.interactive.derivative is a good synchronous example:
tool_status captures the preprocessing controls, and update_data() swaps in the new
array while preserving the current settings.
Add manager-facing metadata¶
The ImageTool manager can display a preview image and rich HTML summary for tools opened from ImageTool. These are optional, but tools feel much more integrated when they provide them.
The working MyTool reference above already implements both, so use it as the baseline
pattern for new synchronous tools.
Implement these properties when they make sense:
preview_imageitem: return thepyqtgraph.ImageItemthat should be rendered in the manager tree.info_text: return a short HTML summary of the current tool state.
Whenever the preview or info text changes, emit sigInfoChanged. This is what causes
the manager to refresh its side panel and thumbnails. KspaceToolGUI and
DerivativeTool are good references for this pattern.
If the tool can change its displayed data or any ImageTool window opened from the tool
without going through the built-in update flow, call
self._notify_data_changed(). That method emits both sigInfoChanged and
sigDataChanged, which is what lets rows created by the tool become stale or update
from the current tool state. Emit sigInfoChanged directly only for metadata-only
changes.
Support updates from ImageTool data¶
If a tool can be launched from ImageTool or opened from ImageTool in the manager, it should usually be able to react when the ImageTool that opened it changes.
ToolWindow gives you three hooks for this:
validate_update_data(new_data): normalize or reject replacement data before it reaches the live UI.update_data(new_data): apply the new data without creating a brand-new window. ReturnFalsewhen the input was accepted but the tool must stay stale until a deferred recomputation or result publication finishes._cancel_background_work(timeout_ms=...): stop worker threads or queued tasks before mutating the UI, if your tool fits in the background.
There are three common update strategies in the current codebase:
In-place updates for simple tools.
DerivativeToolandKspaceToolGUIvalidate the new array, preservetool_status, replace their cached data, and recompute the plots.Rebuild-and-restore updates for tools whose UI depends heavily on the input data.
Fit1DToolandFit2DToolsnapshottool_status, tear down the central widget, rebuild the UI, then restore the saved state. In that case, preferself._perform_source_update(...)so validation and background-task cancellation stay in one place.Deferred updates for tools that accept the new input before they can publish a fresh result.
GoldTool.update_data()returnsFalsewhile a queued data update or refit is still pending, andFit1DTool,Fit2DTool, andResolutionToolreturnFalsewhen they have accepted new data but must finish an asynchronous refit before their current outputs are fresh again. ReturningFalsekeeps the tool marked as stale until that follow-up work finishes and the tool callsfinalize_source_refresh().
When your tool has worker threads, a typical pattern is:
def _cancel_background_work(self, *, timeout_ms: int) -> bool:
return self._threadpool.waitForDone(timeout_ms)
def update_data(self, new_data: xr.DataArray) -> bool:
status = self.tool_status
old_geom = self.saveGeometry()
def _apply_update(validated: xr.DataArray) -> bool:
self._data = validated
self._rebuild_ui()
self.tool_status = status
self.restoreGeometry(old_geom)
self._notify_data_changed()
return True
return self._perform_source_update(new_data, apply_update=_apply_update)
If _apply_update(...) starts asynchronous follow-up work such as a refit, return
False instead and call finalize_source_refresh() only after the new result has been
published. This is what prevents rows created by the tool from updating against old
calculated arrays.
If the tool is launched from an ImageTool selection, the launch site should also record which ImageTool data and selection opened it:
Use
ItoolPlotItem.make_tool_source_spec(...)when the tool is created from the active cursor or cropped selection.Use
erlab.interactive.imagetool.provenance.full_data()when the whole current array should be used again during an update.Use the operation models in
erlab.interactive.imagetool.provenancesuch asQSelOperation(...),IselOperation(...),SelOperation(...),QSelAggregationOperation(...), andTransposeOperation(...)when a tool needs to write or modify the saved operation list explicitly. Pass those operation instances toselection(...)orfull_data(...).When implementing a custom
ToolProvenanceOperation.derivation_entry(), return aDerivationEntryfor steps that should appear in the manager derivation list or copied code. ReturnNoneonly for operations that must still run during an update but should stay hidden from the steps list and generated code, such as an internal bookkeeping rename. If the step should remain visible but code generation should stop, returnDerivationEntry(..., code=None)instead.Ensure the caller sets
set_source_binding(...); the manager wrapper will provideset_source_parent_fetcher(...)andset_input_provenance_parent_fetcher(...)when the tool is attached to an ImageTool in the manager.
If the tool offers “Copy Code” or otherwise generates code from its current input, also implement provenance for that code path:
Implement
COPY_PROVENANCEwith aToolScriptProvenanceDefinitionfor the main copy-code action.Override
current_provenance_spec()only when the declarative script metadata cannot describe the tool’s generated code.Declare outputs in
IMAGE_TOOL_OUTPUTSwhen the tool exposes ImageTool windows as a child row of the tool whose generated code differs from the main tool action. The baseToolWindow.output_imagetool_data()andToolWindow.output_imagetool_provenance()methods resolve those declared outputs for the manager, so authors should not override those methods for new outputs.Override
detached_output_imagetool_provenance()only when non-bound standalone ImageTool launches should use different generated code fromcurrent_provenance_spec(). This provenance is evaluated while opening the new window, so returnNoneor side-effect-free provenance instead of warning the user from inside this hook.
The full MyTool example above already shows the preferred pattern:
COPY_PROVENANCEdescribes the main copy-code path with aToolScriptProvenanceDefinition.self.copy_btn.clicked.connect(self.copy_code)wires a UI button to the built-in copy-code slot.ToolScriptProvenanceDefinition(expression_method=..., assign=...)keeps the class declarative while the framework owns the final assignment target and active variable.IMAGE_TOOL_OUTPUTS[Output.FILTERED]declares the filtered ImageTool window shown as a child row of the tool, withdata_method="_filtered_output"and a second provenance definition whoseassigntarget is"filtered".open_filtered()uses_launch_output_imagetool(..., output_id=self.Output.FILTERED)so the manager can persist and refresh that ImageTool window.
Use the current codebase as the source of truth for variants:
DerivativeToolis the reference foroperations_methodwhen generated code needs more than one operation, i.e., the tool does more than a single function call.KspaceTool,GoldTool,MeshTool, andFit2DToolare good examples for ImageTool windows that appear as child rows of a tool in the manager.Fit1DToolandFit2DToolare good main copy-code references.
The relevant examples live in erlab.interactive.imagetool.plot_items.ItoolPlotItem and
erlab.interactive.imagetool.viewer.ImageSlicerArea as methods named
open_in_<tool-name>.
Expose the tool cleanly¶
After the widget exists, add a public launcher function that users can call directly:
import varname
import xarray as xr
import erlab
def mytool(
data: xr.DataArray, data_name: str | None = None, *, execute: bool | None = None
) -> MyTool:
if data_name is None:
try:
data_name = str(varname.argname("data", func=mytool, vars_only=False))
except varname.VarnameRetrievingError:
data_name = "data"
with erlab.interactive.utils.setup_qapp(execute):
win = MyTool(data, data_name=data_name)
win.show()
win.raise_()
win.activateWindow()
return win
This launcher is what should get the user-facing docstring. Treat it as part of the
real tool API, not as a thin convenience wrapper: built-in tools typically infer
data_name here, then pass that stable name into the ToolWindow instance so generated
code and saved state stay readable.
To make the tool discoverable across ERLabPy, update the relevant entry points:
export it from
src/erlab/interactive/__init__.pyi;add an IPython line magic in
src/erlab/interactive/_magic.pyif the tool is useful from notebooks;add ImageTool menu or context-menu actions if the tool operates on the current view or selection; and
update the user guide so people can find it without reading the source.
If the tool should be available from an ImageTool in the manager, check both the plain ImageTool launch path and the manager launch path. The manager flow is slightly different because the tool row can be hidden, saved, restored, or rebound to watched notebook data.
Test and document the contribution¶
Before opening a PR, make sure the new tool behaves like an ERLabPy tool, not just like a local Qt app.
At minimum, add tests in tests/interactive/test_<tool>.py that cover:
construction and basic interaction;
tool_statusserialization and restoration;to_dataset()/from_dataset()if the tool is savable, including any_append_persistence_payload()/_restore_persistence_payload()roundtrip when the tool uses them;validate_update_data()andupdate_data()branches, including Stale or Unavailable cases after the ImageTool that opened the tool changes;dialog accept and cancel paths for any new dialogs, including Save and Update Now paths if the tool participates in automatic updates; and
manager launch paths, preferably by patching manager functions unless a live manager is required.
If you add a new top-level test module, also update scripts/_ci_test_groups.py so the
CI shards still partition the suite correctly.
Document the new public entry point in two places:
the launcher function and any public class docstrings; and
the user guide page where users would naturally look for the tool.
For GUI-facing contributions, include screenshots or a short recording in the PR, and run the same checks expected for all contributions:
uv run ruff format .uv run ruff check --fix .uv run mypy srcuv run pytestuv run python -m scripts.ci_test_groups --check-partition
If you follow the patterns above, your tool will fit naturally into the existing interactive ecosystem.
Next steps¶
Once you have a working tool, you may want to contribute it to the repository. See the contributing guide for details on how to submit a pull request.