Authoring interactive tools¶
Use this guide to add an interactive analysis tool to erlab.interactive. It
assumes that you can write Qt widgets and work with xarray.DataArray objects.
See Development setup and workflow for repository setup and contribution checks.
Use ToolWindow when a window analyzes a
DataArray and must support save and restore, undo and redo, or
ImageTool Manager integration. A plain Qt window is sufficient for an internal utility
that does not need these features.
See the Interactive-tool authoring API for the protected extension-point signatures.
Required tool interface¶
A ToolWindow subclass must provide these
members:
Member |
Requirement |
|---|---|
Constructor |
Accept the primary |
Use a stable, short name. |
|
Store the lightweight widget state required to restore the window. |
|
Return the primary input array. |
|
Return and apply a complete |
Define the class at module scope. Saved tools store its module and qualified name.
Minimal implementation¶
The following tool displays a scaled two-dimensional array. It has persistent state and undo history, but no optional Manager features.
from __future__ import annotations
import pydantic
import pyqtgraph as pg
import xarray as xr
from qtpy import QtWidgets, QtCore
import erlab
class ScaleTool(erlab.interactive.utils.ToolWindow):
tool_name = "scaletool"
class StateModel(pydantic.BaseModel):
scale: float = 1.0
def __init__(self, data: xr.DataArray) -> None:
super().__init__()
self._data = self._validate_data(data)
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.plot.addItem(self.image)
layout.addWidget(self.plot)
layout.addWidget(self.scale_spin)
self.scale_spin.valueChanged.connect(self._scale_changed)
self._refresh()
self._reset_history_stack()
@staticmethod
def _validate_data(data: xr.DataArray) -> xr.DataArray:
parsed = erlab.interactive.utils.parse_data(data)
if parsed.ndim != 2:
raise ValueError("`data` must be two-dimensional")
return parsed
@property
def tool_data(self) -> xr.DataArray:
return self._data
@property
def tool_status(self) -> StateModel:
return self.StateModel(scale=float(self.scale_spin.value()))
@tool_status.setter
def tool_status(self, status: StateModel) -> None:
with QtCore.QSignalBlocker(self.scale_spin):
self.scale_spin.setValue(status.scale)
self._refresh()
def _scale_changed(self, _value: float) -> None:
self._refresh()
self._write_state()
def _refresh(self) -> None:
self.image.setDataArray(self.tool_data * self.scale_spin.value())
Use ToolWindow.setCentralWidget(), not the base
QMainWindow implementation. The override retains the status area used for source
updates.
Call _write_state() after an
undoable user action. Do not call it from the
tool_status setter. The setter
can update dependent displays, but it must not record a user action.
Reset history after the initial widget state is complete. The saved state model must contain enough information for its setter to reproduce the visible tool state.
Add a public launcher beside the class:
def scaletool(
data: xr.DataArray, *, execute: bool | None = None
) -> ScaleTool:
"""Open the scale tool."""
with erlab.interactive.utils.setup_qapp(execute):
window = ScaleTool(data)
window.show()
window.raise_()
window.activateWindow()
return window
For example:
import erlab
from erlab.io.exampledata import generate_data_angles
data = generate_data_angles().qsel(beta=0.0)
window = scaletool(data)
State notifications¶
Use the narrowest notification that describes the change.
Change |
Action |
|---|---|
An undoable control changes |
Call |
A saved setting changes without an undo step |
Emit |
Only |
Emit |
Displayed data or a Manager-visible output changes |
Call |
_notify_data_changed()
updates Manager information and output data. During a source refresh, the base class
delays the data-change signal until the complete input update succeeds.
Input updates¶
Implement input updates only when an existing window must follow data from ImageTool. The framework passes one complete mapping of declared input names.
from collections.abc import Mapping
def validate_update_inputs(
self, inputs: Mapping[str, xr.DataArray]
) -> Mapping[str, xr.DataArray]:
validated = dict(super().validate_update_inputs(inputs))
validated["data"] = self._validate_data(validated["data"])
return validated
def update_inputs(self, inputs: Mapping[str, xr.DataArray]) -> bool:
status = self.tool_status
self._data = inputs["data"]
with self._history_suppressed():
self.tool_status = status
self._reset_history_stack()
return True
The update must modify the existing window. Do not replace it with a new window.
Return the same input names from validate_update_inputs(). The framework rejects
missing or additional names. Return False from update_inputs() if the input was not
applied.
For a tool with background work, override
_cancel_background_work() so an update cannot
replace data while an old worker still uses it.
If an update finishes asynchronously, use this sequence:
Call
_defer_source_refresh()beforeupdate_inputs()returnsFalse.Apply the result only if it still belongs to the current request.
Call
finalize_source_refresh()after publication.Call
abort_source_refresh()if the work stops before publication.
See GoldTool for an asynchronous
implementation.
Persistence¶
to_dataset() stores
tool_data separately from
tool_status. Keep arrays,
fit results, and other large values out of the state model so history snapshots stay
small.
Use extra persistence hooks only when the default data and state are insufficient.
Requirement |
Hooks |
|---|---|
Additional |
|
Other save-only values |
|
Optional expensive restore work |
When you override _persistence_data_items(), retain the items returned by super().
Add each additional input under its declared input name. A multi-input tool must also
be constructible from its primary data argument alone so saved state can restore the
remaining inputs.
Direct from_dataset() calls
restore optional work immediately. Manager workspace loading can defer work for hidden
tools. Put validation and state required for a usable object outside the deferred
callback.
Do not start optional restore calculations unconditionally in the constructor. A queued callback can be discarded if the user closes the hidden tool before it is needed.
Manager presentation¶
These properties are optional:
preview_imageitemsupplies the Manager thumbnail.info_textsupplies a short HTML summary for the Details view.
Emit sigInfoChanged when
either value changes. Use
_notify_data_changed()
instead when output data also changes.
Generated code¶
Set COPY_PROVENANCE when
the tool has a user-facing Copy Code action. Prefer one expression and assignment
for one analysis operation. Add these members to the tool class:
import typing
class ScaleTool(erlab.interactive.utils.ToolWindow):
# Keep the required members from the minimal implementation.
COPY_PROVENANCE: typing.ClassVar = (
erlab.interactive.utils.ToolScriptProvenanceDefinition(
start_label="Start from the current scaletool input",
label="Scale the data",
expression_method="_scale_expression",
assign="scaled",
)
)
def _scale_expression(
self,
*,
primary_input: str | None = None,
data: xr.DataArray | None = None,
) -> str:
del data
source = primary_input or "data"
return f"{source} * {self.scale_spin.value()!r}"
Connect the visible button to
copy_code(). The declaration
defines the code. It does not create a button.
Generated code is user-facing Python. Use public APIs and meaningful variable names. Test the code by executing it and comparing the result. Do not test only the string format.
Use operations_method only when one expression cannot represent the action. See
DerivativeTool for this case.
ImageTool outputs¶
Declare an output when an ImageTool opened by the tool must remain attached to it in the Manager and refresh with it. Add these members to the tool class:
import enum
import typing
class ScaleTool(erlab.interactive.utils.ToolWindow):
# Keep the required members and _scale_expression() from the earlier examples.
class Output(enum.StrEnum):
SCALED = "scaletool.scaled"
IMAGE_TOOL_OUTPUTS: typing.ClassVar = {
Output.SCALED: erlab.interactive.utils.ToolImageOutputDefinition(
data_method="_scaled_data",
provenance=erlab.interactive.utils.ToolScriptProvenanceDefinition(
start_label="Start from the current scaletool input",
label="Scale the data",
expression_method="_scale_expression",
assign="scaled",
),
)
}
def _scaled_data(self) -> xr.DataArray:
return self.tool_data * self.scale_spin.value()
def open_scaled_data(self) -> None:
self._launch_output_imagetool(
self._scaled_data(),
output_id=self.Output.SCALED,
)
Use a stable, tool-qualified output ID. Workspace files store this value. The output data and its provenance must describe the same calculation.
Declaring an output does not create an action. Connect a button or menu action to the
method that calls
_launch_output_imagetool().
For an independent ImageTool window, use
_launch_detached_output_imagetool(). If that window
needs generated code, pass the result of
detached_output_imagetool_provenance() explicitly.
The launch helper does not call the provenance hook.
See Fit2DTool for several declared
outputs.
Public integration¶
For a built-in ERLabPy tool:
Add the runtime module under
src/erlab/interactive/.Export the public launcher from
src/erlab/interactive/__init__.pyi.Add an IPython line magic in
src/erlab/interactive/_magic.pywhen notebook users need one.Add ImageTool or Manager actions only where the tool applies to the visible data.
Add the public launcher to the API reference. Add user documentation for the tasks that the tool supports.
An external package must register each restorable tool class:
[project.entry-points."erlab.interactive.tool_windows"]
scale-tool = "my_package.tools:ScaleTool"
The entry-point value must match the class module and qualified name. A class defined only in a notebook or script cannot be discovered by a fresh Manager process.
Tests¶
The core test must cover state and data restoration:
def test_scale_tool_roundtrip(qtbot):
data = xr.DataArray(
[[0.0, 1.0], [2.0, 3.0]],
dims=("alpha", "eV"),
name="example",
)
tool = ScaleTool(data)
qtbot.addWidget(tool)
tool.scale_spin.setValue(2.5)
restored = erlab.interactive.utils.ToolWindow.from_dataset(tool.to_dataset())
qtbot.addWidget(restored)
assert isinstance(restored, ScaleTool)
assert restored.tool_status == tool.tool_status
xr.testing.assert_identical(restored.tool_data, data)
Add only the tests that match the features that the tool implements:
Feature |
Required coverage |
|---|---|
Source updates |
Validate and apply the complete input mapping. Confirm that the existing window and its settings remain valid. |
Background work |
Cover cancellation, stale results, failure, and close behavior. Test both Qt bindings for lifetime-sensitive code. |
Extra persisted values |
Round-trip each persistence hook. |
Generated code |
Execute the code in an explicit namespace and compare the result with the tool output. |
Declared ImageTool outputs |
Cover opening, refreshing, persistence, and matching generated code. |
Dialogs |
Cover accepted and cancelled paths. |
If you add a top-level test module, update scripts/_ci_test_groups.py. See
Development setup and workflow for the full test and pull-request workflow.
Production examples¶
Use current tools as implementation references:
Requirement |
Example |
|---|---|
Synchronous calculations and source updates |
|
Coordinate conversion and a declared ImageTool output |
|
Asynchronous work and source refresh |
|
Multiple inputs and several declared outputs |
The launcher functions are the public user interfaces. Treat the classes above as implementation examples.