Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
source, OK reuses a completed current preview once; stale, running and
multi-selection previews fall back to normal processing.
- **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.
- **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.

### Changed

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

- **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.
- **Parameter forms:** a checkbox that has only its own text, as in DataLab Desktop, no longer shows its internal field name as a label.

## [0.9.0] - 2026-08-11

Expand Down
13 changes: 12 additions & 1 deletion doc/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,18 @@ and may implement `materialize_example()`. Recipe slots carry signal/image type,
`RecipeOutcome`; DataLab-Web commits objects, anchored scalar results,
diagnostics, and provenance transactionally.

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.
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.

### Tools and instruments

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.

A tool is opened in one of two ways:

- **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.
- **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.

`datalab.plugin_tools`, `datalab.plugin_instruments` and `datalab.plugin_resources` are copied from DataLab Desktop into the host shim, like `datalab.recipe_binding`.

Version 1 of the installer deliberately accepts only local `*-none-any`
pure-Python wheels compatible with Pyodide's Python version. Native payloads,
Expand Down
105 changes: 105 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
JsonSchema,
PanelTree,
PluginExampleOpenResult,
PluginInstrumentAcquisition,
PluginMenuAction,
PluginRecord,
PluginRecipeCommit,
Expand Down Expand Up @@ -117,6 +118,7 @@ import {
ApplicationsDialog,
type PluginExampleContext,
} from "./components/ApplicationsDialog";
import { InstrumentWindow } from "./components/InstrumentWindow";
import { ObjectPropertiesDialog } from "./components/ObjectPropertiesDialog";
import { RoiPanel } from "./components/RoiPanel";
import type { RoiDrawGeometry } from "./components/RoiPanel";
Expand Down Expand Up @@ -848,6 +850,11 @@ export default function App() {
// (possibly reduced) one set on opening it; its values prefill recipes.
const [pluginExampleContext, setPluginExampleContext] =
useState<PluginExampleContext | null>(null);
// Plugin tool whose instrument window is open.
const [instrumentTool, setInstrumentTool] = useState<{
pluginId: string;
toolId: string;
} | null>(null);
const [annotations, setAnnotations] = useState<PlotlyAnnotations>({
shapes: [],
annotations: [],
Expand Down Expand Up @@ -1672,6 +1679,8 @@ export default function App() {
const handleReloadPlugins = useCallback(async () => {
if (!runtime) return;
setBusy(true);
// Reloaded plugins create new instruments
setInstrumentTool(null);
try {
await runtime.reloadPlugins();
await refreshPluginActions();
Expand Down Expand Up @@ -1772,6 +1781,85 @@ export default function App() {
[],
);

const handleLaunchApplicationTool = useCallback(
async (pluginId: string, toolId: string) => {
if (!runtime) return;
const tool = pluginRecords
.find((record) => record.plugin_id === pluginId)
?.tools.find((item) => item.id === toolId);
if (tool?.kind === "instrument") {
setInstrumentTool({ pluginId, toolId });
return;
}
setBusy(true);
try {
const added = await runtime.launchPluginTool(
pluginId,
toolId,
selectedIds.length > 0 ? selectedIds : currentId ? [currentId] : [],
);
const newImage = added.image[added.image.length - 1] ?? null;
const newSignal = added.signal[added.signal.length - 1] ?? null;
if (newImage && treeKind !== "image") {
await refreshPanelKind("image", newImage);
} else if (newSignal && treeKind !== "signal") {
await refreshPanelKind("signal", newSignal);
} else {
await refresh(
treeKind === "image" ? newImage : (newSignal ?? newImage),
);
}
} catch (err) {
await showProcessingError({
context: t("Plugin action: {action}", {
action: tool?.title ?? toolId,
}),
traceback: err instanceof Error ? err.message : String(err),
});
} finally {
setBusy(false);
}
},
[
currentId,
pluginRecords,
refresh,
refreshPanelKind,
runtime,
selectedIds,
showProcessingError,
treeKind,
],
);

const handleInstrumentAcquired = useCallback(
async (result: PluginInstrumentAcquisition) => {
const lastId = result.object_ids[result.object_ids.length - 1] ?? null;
await refreshPanelKind(result.panel, lastId);
const visualSelection =
result.panel === "signal" &&
result.object_ids.length > MULTI_SIGNAL_LIMIT
? lastId
? [lastId]
: []
: result.object_ids;
setSelectedIds(visualSelection);
setCurrentId(lastId);
// Methods started on the visible selection use the whole acquisition
setPluginExampleContext(
instrumentTool
? {
pluginId: instrumentTool.pluginId,
objectIds: result.object_ids,
visibleIds: visualSelection,
parameterValues: {},
}
: null,
);
},
[instrumentTool, refreshPanelKind, setSelectedIds],
);

const handleOpenApplicationExample = useCallback(
async (pluginId: string, exampleId: string) => {
if (!runtime || !(await confirmOpenPluginExample())) return;
Expand Down Expand Up @@ -4448,6 +4536,9 @@ export default function App() {
onOpenApplicationExample: (pluginId, exampleId) => {
void handleOpenApplicationExample(pluginId, exampleId);
},
onLaunchApplicationTool: (pluginId, toolId) => {
void handleLaunchApplicationTool(pluginId, toolId);
},
onOpenManager: () => setPluginManagerOpen(true),
onReloadAll: handleReloadPlugins,
}),
Expand Down Expand Up @@ -4509,6 +4600,7 @@ export default function App() {
handleTriggerPluginAction,
handleOpenApplicationRecipe,
handleOpenApplicationExample,
handleLaunchApplicationTool,
handleReloadPlugins,
interactiveFits,
handleLaunchInteractiveFit,
Expand Down Expand Up @@ -5383,14 +5475,27 @@ export default function App() {
{applicationsOpen && (
<ApplicationsDialog
candidateIds={applicationCandidateIds}
activePanel={treeKind}
exampleContext={pluginExampleContext}
initialTarget={applicationTarget}
confirmOpenExample={confirmOpenPluginExample}
onCommitted={handlePluginRecipeCommitted}
onExampleOpened={handlePluginExampleOpened}
onOpenTool={(pluginId, toolId) => {
void handleLaunchApplicationTool(pluginId, toolId);
}}
onClose={() => setApplicationsOpen(false)}
/>
)}
{instrumentTool && (
<InstrumentWindow
key={`${instrumentTool.pluginId}:${instrumentTool.toolId}`}
pluginId={instrumentTool.pluginId}
toolId={instrumentTool.toolId}
onAcquired={handleInstrumentAcquired}
onClose={() => setInstrumentTool(null)}
/>
)}
{separateViewOpen &&
(() => {
// Build the popout payload lazily so we don't allocate the
Expand Down
24 changes: 23 additions & 1 deletion src/actions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import datalabIconUrl from "../assets/DataLab.svg?url";
import { t } from "../i18n/translate";
import type { SupportedLocale } from "../i18n/locale";
import type { SignalLayoutMode } from "../components/signalPlotLayout";
import { toolSelectionIssue } from "../utils/pluginTools";
import type { ActionDescriptor, ActionState } from "./types";

/** Callbacks needed to build the static (non-feature) actions. */
Expand Down Expand Up @@ -1188,6 +1189,7 @@ export interface PluginActionCallbacks {
onOpenApplications: () => void;
onOpenApplicationRecipe: (pluginId: string, recipeId: string) => void;
onOpenApplicationExample: (pluginId: string, exampleId: string) => void;
onLaunchApplicationTool: (pluginId: string, toolId: string) => void;
onOpenManager: () => void;
onReloadAll: () => void;
}
Expand Down Expand Up @@ -1288,7 +1290,27 @@ export function buildPluginActions(
run: () => cb.onOpenApplicationExample(pluginId, example.id),
}),
);
return [...recipes, ...examples];
// Tools follow their panel; tools without object type are in both
const tools = record.tools
.filter(
(tool) =>
tool.object_type === null || tool.object_type === activePanel,
)
.map<ActionDescriptor>((tool, index) => ({
id: `plugin.application.tool.${pluginId}.${tool.id}`,
label: tool.title,
menuPath: `Plugins/${pluginName}/${tool.title}`,
beginGroup: recipes.length + examples.length > 0 && index === 0,
enabled: (s) =>
ready(s) &&
toolSelectionIssue(
tool,
activePanel,
s.selectedIds.length || (s.currentId !== null ? 1 : 0),
) === null,
run: () => cb.onLaunchApplicationTool(pluginId, tool.id),
}));
return [...recipes, ...examples, ...tools];
},
);

Expand Down
Loading