Skip to content

Commit 438fdfa

Browse files
Merge pull request #23 from DataLab-Platform/feat/plugin-tools
feat(applications): support plugin tools and instrument windows
2 parents 9d5c818 + 61cc79d commit 438fdfa

29 files changed

Lines changed: 2210 additions & 16 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1717
source, OK reuses a completed current preview once; stale, running and
1818
multi-selection previews fall back to normal processing.
1919
- **Add metadata:** _Edit > Metadata > Add metadata…_ can now extract the value from the formatted text with a regular expression, for example an exposure time or a shot number read from object titles. Objects without a match are left unchanged unless you ask for an error, a scale factor converts numeric values (e.g. milliseconds to seconds), and a live preview shows the resulting values. Keys may contain dots and hyphens, as used by plugin keys, and a **Known keys** list copies a key already present on the selected objects. Invalid settings are reported and the dialog reopens with your entries.
20+
- **Application tools:** applications can now offer tools, listed in a **Tools** section of the Applications window and in their _Plugins_ submenu, and enabled only when the selection suits them. Instrument tools open a window with a live view next to their settings, and add each acquisition to a new group. The bundled Camera and Pulse applications use it for a scientific camera simulator and an oscilloscope simulator, whose acquisitions are ready for their methods.
2021

2122
### Changed
2223

@@ -35,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
3536
### Fixed
3637

3738
- **Plugin and macro dialogs:** fields enabled by another option, and values computed from other fields, now update while the dialog is open; they previously stayed frozen until the dialog was closed.
39+
- **Parameter forms:** a checkbox that has only its own text, as in DataLab Desktop, no longer shows its internal field name as a label.
3840

3941
## [0.9.0] - 2026-08-11
4042

‎doc/plugins.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,18 @@ and may implement `materialize_example()`. Recipe slots carry signal/image type,
130130
`RecipeOutcome`; DataLab-Web commits objects, anchored scalar results,
131131
diagnostics, and provenance transactionally.
132132

133-
The Applications dialog presents each recipe as a method card: expected inputs, a readiness status re-assessed when the selection changes, a _Run on selection…_ button, and _Try with this example_ for each example designed for it. Examples without a recipe are listed as datasets. Readiness and binding checks use the same `datalab.recipe_binding` module as DataLab Desktop, copied into the host shim, so both hosts accept the same selections. Desktop-only plugin tools are not listed.
133+
The Applications dialog presents each recipe as a method card: expected inputs, a readiness status re-assessed when the selection changes, a _Run on selection…_ button, and _Try with this example_ for each example designed for it. Examples without a recipe are listed as datasets. Readiness and binding checks use the same `datalab.recipe_binding` module as DataLab Desktop, copied into the host shim, so both hosts accept the same selections.
134+
135+
### Tools and instruments
136+
137+
Application plugins may also declare `TOOLS`, a tuple of `datalab.plugin_tools.PluginTool` values, as on DataLab Desktop. The Applications dialog lists them in a **Tools** section, and the _Plugins_ menu lists them in the plugin's submenu, after its methods and examples. `object_type` places a tool in the signal or image menu (both when `None`), and `selection` (`none`, `exactly_one`, `at_least_one`, `at_least_two`) enables it only when enough objects of that type are selected; otherwise the dialog tells which objects to select.
138+
139+
A tool is opened in one of two ways:
140+
141+
- **Launcher** (`launcher="method_name"`): DataLab calls the plugin method, and awaits it when it is a coroutine, so it can open dialogs with `edit_async()`. `self.get_selected_objects()` returns the objects selected when the tool was launched; objects the tool adds are refreshed and selected afterwards.
142+
- **Instrument** (`instrument="method_name"`): the method returns a `datalab.plugin_instruments.PluginInstrument`, which holds its settings in a guidata `DataSet` and implements `preview()` (an `InstrumentFrame`: signals drawn together, or one image, with a summary and an optional fixed range) and `acquire()` (an `InstrumentAcquisition`: a group title and the acquired objects). DataLab draws the window itself: a live view on the left, the settings form on the right, a _Live_ toggle refreshing the view every `live_interval_ms`, and an _Acquire_ button adding each acquisition to a new group. The instrument instance lives as long as the plugin is registered, so settings are kept between openings. The plugin writes no user interface code, so the same instrument runs on DataLab Desktop and DataLab-Web.
143+
144+
`datalab.plugin_tools`, `datalab.plugin_instruments` and `datalab.plugin_resources` are copied from DataLab Desktop into the host shim, like `datalab.recipe_binding`.
134145

135146
Version 1 of the installer deliberately accepts only local `*-none-any`
136147
pure-Python wheels compatible with Pyodide's Python version. Native payloads,

‎src/App.tsx‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
JsonSchema,
2626
PanelTree,
2727
PluginExampleOpenResult,
28+
PluginInstrumentAcquisition,
2829
PluginMenuAction,
2930
PluginRecord,
3031
PluginRecipeCommit,
@@ -117,6 +118,7 @@ import {
117118
ApplicationsDialog,
118119
type PluginExampleContext,
119120
} from "./components/ApplicationsDialog";
121+
import { InstrumentWindow } from "./components/InstrumentWindow";
120122
import { ObjectPropertiesDialog } from "./components/ObjectPropertiesDialog";
121123
import { RoiPanel } from "./components/RoiPanel";
122124
import type { RoiDrawGeometry } from "./components/RoiPanel";
@@ -848,6 +850,11 @@ export default function App() {
848850
// (possibly reduced) one set on opening it; its values prefill recipes.
849851
const [pluginExampleContext, setPluginExampleContext] =
850852
useState<PluginExampleContext | null>(null);
853+
// Plugin tool whose instrument window is open.
854+
const [instrumentTool, setInstrumentTool] = useState<{
855+
pluginId: string;
856+
toolId: string;
857+
} | null>(null);
851858
const [annotations, setAnnotations] = useState<PlotlyAnnotations>({
852859
shapes: [],
853860
annotations: [],
@@ -1672,6 +1679,8 @@ export default function App() {
16721679
const handleReloadPlugins = useCallback(async () => {
16731680
if (!runtime) return;
16741681
setBusy(true);
1682+
// Reloaded plugins create new instruments
1683+
setInstrumentTool(null);
16751684
try {
16761685
await runtime.reloadPlugins();
16771686
await refreshPluginActions();
@@ -1772,6 +1781,85 @@ export default function App() {
17721781
[],
17731782
);
17741783

1784+
const handleLaunchApplicationTool = useCallback(
1785+
async (pluginId: string, toolId: string) => {
1786+
if (!runtime) return;
1787+
const tool = pluginRecords
1788+
.find((record) => record.plugin_id === pluginId)
1789+
?.tools.find((item) => item.id === toolId);
1790+
if (tool?.kind === "instrument") {
1791+
setInstrumentTool({ pluginId, toolId });
1792+
return;
1793+
}
1794+
setBusy(true);
1795+
try {
1796+
const added = await runtime.launchPluginTool(
1797+
pluginId,
1798+
toolId,
1799+
selectedIds.length > 0 ? selectedIds : currentId ? [currentId] : [],
1800+
);
1801+
const newImage = added.image[added.image.length - 1] ?? null;
1802+
const newSignal = added.signal[added.signal.length - 1] ?? null;
1803+
if (newImage && treeKind !== "image") {
1804+
await refreshPanelKind("image", newImage);
1805+
} else if (newSignal && treeKind !== "signal") {
1806+
await refreshPanelKind("signal", newSignal);
1807+
} else {
1808+
await refresh(
1809+
treeKind === "image" ? newImage : (newSignal ?? newImage),
1810+
);
1811+
}
1812+
} catch (err) {
1813+
await showProcessingError({
1814+
context: t("Plugin action: {action}", {
1815+
action: tool?.title ?? toolId,
1816+
}),
1817+
traceback: err instanceof Error ? err.message : String(err),
1818+
});
1819+
} finally {
1820+
setBusy(false);
1821+
}
1822+
},
1823+
[
1824+
currentId,
1825+
pluginRecords,
1826+
refresh,
1827+
refreshPanelKind,
1828+
runtime,
1829+
selectedIds,
1830+
showProcessingError,
1831+
treeKind,
1832+
],
1833+
);
1834+
1835+
const handleInstrumentAcquired = useCallback(
1836+
async (result: PluginInstrumentAcquisition) => {
1837+
const lastId = result.object_ids[result.object_ids.length - 1] ?? null;
1838+
await refreshPanelKind(result.panel, lastId);
1839+
const visualSelection =
1840+
result.panel === "signal" &&
1841+
result.object_ids.length > MULTI_SIGNAL_LIMIT
1842+
? lastId
1843+
? [lastId]
1844+
: []
1845+
: result.object_ids;
1846+
setSelectedIds(visualSelection);
1847+
setCurrentId(lastId);
1848+
// Methods started on the visible selection use the whole acquisition
1849+
setPluginExampleContext(
1850+
instrumentTool
1851+
? {
1852+
pluginId: instrumentTool.pluginId,
1853+
objectIds: result.object_ids,
1854+
visibleIds: visualSelection,
1855+
parameterValues: {},
1856+
}
1857+
: null,
1858+
);
1859+
},
1860+
[instrumentTool, refreshPanelKind, setSelectedIds],
1861+
);
1862+
17751863
const handleOpenApplicationExample = useCallback(
17761864
async (pluginId: string, exampleId: string) => {
17771865
if (!runtime || !(await confirmOpenPluginExample())) return;
@@ -4448,6 +4536,9 @@ export default function App() {
44484536
onOpenApplicationExample: (pluginId, exampleId) => {
44494537
void handleOpenApplicationExample(pluginId, exampleId);
44504538
},
4539+
onLaunchApplicationTool: (pluginId, toolId) => {
4540+
void handleLaunchApplicationTool(pluginId, toolId);
4541+
},
44514542
onOpenManager: () => setPluginManagerOpen(true),
44524543
onReloadAll: handleReloadPlugins,
44534544
}),
@@ -4509,6 +4600,7 @@ export default function App() {
45094600
handleTriggerPluginAction,
45104601
handleOpenApplicationRecipe,
45114602
handleOpenApplicationExample,
4603+
handleLaunchApplicationTool,
45124604
handleReloadPlugins,
45134605
interactiveFits,
45144606
handleLaunchInteractiveFit,
@@ -5383,14 +5475,27 @@ export default function App() {
53835475
{applicationsOpen && (
53845476
<ApplicationsDialog
53855477
candidateIds={applicationCandidateIds}
5478+
activePanel={treeKind}
53865479
exampleContext={pluginExampleContext}
53875480
initialTarget={applicationTarget}
53885481
confirmOpenExample={confirmOpenPluginExample}
53895482
onCommitted={handlePluginRecipeCommitted}
53905483
onExampleOpened={handlePluginExampleOpened}
5484+
onOpenTool={(pluginId, toolId) => {
5485+
void handleLaunchApplicationTool(pluginId, toolId);
5486+
}}
53915487
onClose={() => setApplicationsOpen(false)}
53925488
/>
53935489
)}
5490+
{instrumentTool && (
5491+
<InstrumentWindow
5492+
key={`${instrumentTool.pluginId}:${instrumentTool.toolId}`}
5493+
pluginId={instrumentTool.pluginId}
5494+
toolId={instrumentTool.toolId}
5495+
onAcquired={handleInstrumentAcquired}
5496+
onClose={() => setInstrumentTool(null)}
5497+
/>
5498+
)}
53945499
{separateViewOpen &&
53955500
(() => {
53965501
// Build the popout payload lazily so we don't allocate the

‎src/actions/registry.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import datalabIconUrl from "../assets/DataLab.svg?url";
2222
import { t } from "../i18n/translate";
2323
import type { SupportedLocale } from "../i18n/locale";
2424
import type { SignalLayoutMode } from "../components/signalPlotLayout";
25+
import { toolSelectionIssue } from "../utils/pluginTools";
2526
import type { ActionDescriptor, ActionState } from "./types";
2627

2728
/** Callbacks needed to build the static (non-feature) actions. */
@@ -1188,6 +1189,7 @@ export interface PluginActionCallbacks {
11881189
onOpenApplications: () => void;
11891190
onOpenApplicationRecipe: (pluginId: string, recipeId: string) => void;
11901191
onOpenApplicationExample: (pluginId: string, exampleId: string) => void;
1192+
onLaunchApplicationTool: (pluginId: string, toolId: string) => void;
11911193
onOpenManager: () => void;
11921194
onReloadAll: () => void;
11931195
}
@@ -1288,7 +1290,27 @@ export function buildPluginActions(
12881290
run: () => cb.onOpenApplicationExample(pluginId, example.id),
12891291
}),
12901292
);
1291-
return [...recipes, ...examples];
1293+
// Tools follow their panel; tools without object type are in both
1294+
const tools = record.tools
1295+
.filter(
1296+
(tool) =>
1297+
tool.object_type === null || tool.object_type === activePanel,
1298+
)
1299+
.map<ActionDescriptor>((tool, index) => ({
1300+
id: `plugin.application.tool.${pluginId}.${tool.id}`,
1301+
label: tool.title,
1302+
menuPath: `Plugins/${pluginName}/${tool.title}`,
1303+
beginGroup: recipes.length + examples.length > 0 && index === 0,
1304+
enabled: (s) =>
1305+
ready(s) &&
1306+
toolSelectionIssue(
1307+
tool,
1308+
activePanel,
1309+
s.selectedIds.length || (s.currentId !== null ? 1 : 0),
1310+
) === null,
1311+
run: () => cb.onLaunchApplicationTool(pluginId, tool.id),
1312+
}));
1313+
return [...recipes, ...examples, ...tools];
12921314
},
12931315
);
12941316

0 commit comments

Comments
 (0)