diff --git a/CHANGELOG.md b/CHANGELOG.md index b162ed4..21474ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/doc/plugins.md b/doc/plugins.md index 4137d56..c4ac6a1 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 3f7032d..df06bc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import type { JsonSchema, PanelTree, PluginExampleOpenResult, + PluginInstrumentAcquisition, PluginMenuAction, PluginRecord, PluginRecipeCommit, @@ -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"; @@ -848,6 +850,11 @@ export default function App() { // (possibly reduced) one set on opening it; its values prefill recipes. const [pluginExampleContext, setPluginExampleContext] = useState(null); + // Plugin tool whose instrument window is open. + const [instrumentTool, setInstrumentTool] = useState<{ + pluginId: string; + toolId: string; + } | null>(null); const [annotations, setAnnotations] = useState({ shapes: [], annotations: [], @@ -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(); @@ -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; @@ -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, }), @@ -4509,6 +4600,7 @@ export default function App() { handleTriggerPluginAction, handleOpenApplicationRecipe, handleOpenApplicationExample, + handleLaunchApplicationTool, handleReloadPlugins, interactiveFits, handleLaunchInteractiveFit, @@ -5383,14 +5475,27 @@ export default function App() { {applicationsOpen && ( { + void handleLaunchApplicationTool(pluginId, toolId); + }} onClose={() => setApplicationsOpen(false)} /> )} + {instrumentTool && ( + setInstrumentTool(null)} + /> + )} {separateViewOpen && (() => { // Build the popout payload lazily so we don't allocate the diff --git a/src/actions/registry.ts b/src/actions/registry.ts index 2c6dcb5..b7fd62b 100644 --- a/src/actions/registry.ts +++ b/src/actions/registry.ts @@ -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. */ @@ -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; } @@ -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((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]; }, ); diff --git a/src/components/ApplicationsDialog.tsx b/src/components/ApplicationsDialog.tsx index 29d6c70..dd15269 100644 --- a/src/components/ApplicationsDialog.tsx +++ b/src/components/ApplicationsDialog.tsx @@ -3,6 +3,7 @@ import { getRootIconUrl } from "../assets/rootIcons"; import { t } from "../i18n/translate"; import { useRuntime } from "../runtime/RuntimeContext"; import type { + PanelKind, PluginExampleOpenResult, PluginRecipeAssessment, PluginRecord, @@ -10,6 +11,7 @@ import type { PluginRecipePreparation, } from "../runtime/runtime"; import { usePersistedBool } from "../utils/persisted"; +import { toolSelectionIssue } from "../utils/pluginTools"; import { ApplicationMethodCard } from "./ApplicationMethodCard"; import { DataSetDialog } from "./DataSetDialog"; import { RecipeInputResolverDialog } from "./RecipeInputResolverDialog"; @@ -27,11 +29,14 @@ export interface PluginExampleContext { interface Props { candidateIds: string[]; + /** Panel of the candidates; tools of the other panel need a switch. */ + activePanel: PanelKind; exampleContext?: PluginExampleContext | null; initialTarget?: { pluginId: string; recipeId: string } | null; confirmOpenExample: () => boolean | Promise; onCommitted: (commit: PluginRecipeCommit) => void | Promise; onExampleOpened: (result: PluginExampleOpenResult) => void | Promise; + onOpenTool: (pluginId: string, toolId: string) => void; onClose: () => void; } @@ -52,7 +57,9 @@ const READINESS_DELAY_MS = 250; export const APPLICATIONS_LIST_COLLAPSED_KEY = "datalab-web.applications.listCollapsed"; -/** Accordion key of the datasets section (recipe IDs contain the plugin ID). */ +/** Accordion keys of the tools and datasets sections (recipe IDs contain the + * plugin ID, so they never start with a colon). */ +const TOOLS_SECTION = ":tools"; const DATASETS_SECTION = ":datasets"; function isApplication(record: PluginRecord): boolean { @@ -61,7 +68,9 @@ function isApplication(record: PluginRecord): boolean { record.loaded && record.plugin_id && record.info?.capabilities.includes("application") && - (record.recipes.length > 0 || record.examples.length > 0), + (record.recipes.length > 0 || + record.examples.length > 0 || + record.tools.length > 0), ); } @@ -104,11 +113,13 @@ function withValues( export function ApplicationsDialog({ candidateIds, + activePanel, exampleContext = null, initialTarget = null, confirmOpenExample, onCommitted, onExampleOpened, + onOpenTool, onClose, }: Props) { const { runtime } = useRuntime(); @@ -402,6 +413,7 @@ export function ApplicationsDialog({ const datasets = selected?.examples.filter((example) => example.recipe_ids.length === 0) ?? []; + const tools = selected?.tools ?? []; const listToggleLabel = listCollapsed ? t("Show the application list") : t("Hide the application list"); @@ -413,7 +425,11 @@ export function ApplicationsDialog({ const defaultSection = focusedRecipeId ?? selected?.recipes[0]?.id ?? - (datasets.length > 0 ? DATASETS_SECTION : null); + (tools.length > 0 + ? TOOLS_SECTION + : datasets.length > 0 + ? DATASETS_SECTION + : null); const openSection = selectedPluginId !== null && selectedPluginId in openSections ? openSections[selectedPluginId] @@ -512,7 +528,9 @@ export function ApplicationsDialog({ - {(selected.recipes.length > 0 || datasets.length > 0) && ( + {(selected.recipes.length > 0 || + tools.length > 0 || + datasets.length > 0) && (
{selected.recipes.map((recipe) => ( ))} + {tools.length > 0 && ( +
+ toggleSection( + TOOLS_SECTION, + event.currentTarget.open, + ) + } + > + + {t("Tools ({count})", { count: tools.length })} + +
+ {tools.map((tool) => { + const issue = toolSelectionIssue( + tool, + activePanel, + candidateIds.length, + ); + return ( +
+
+ {tool.title} + {tool.description && ( +

{tool.description}

+ )} +
+ +
+ ); + })} +
+
+ )} {datasets.length > 0 && (
}) { + const theme = usePlotlyTheme(); + const traces = useMemo( + () => + frame.items.map((data, index) => { + const reduced = reduceSignalForPlot(data.x, data.y, { width: 900 }); + const style = getCurveStyle(index, 1.5); + const fragment = buildCurveTrace( + reduced.x, + reduced.y, + style.color, + style.width, + style.dash, + ); + return { + ...fragment, + x: fragment.x ?? reduced.x, + y: fragment.y ?? reduced.y, + name: data.title, + }; + }), + [frame.items], + ); + const first = frame.items[0]; + const range = frame.value_range; + return ( + 1, + legend: { orientation: "h", x: 0, y: 1.08 }, + margin: { l: 58, r: 22, t: 30, b: 48 }, + xaxis: { + ...theme.xaxis, + title: { text: axisTitle(first.xlabel, first.xunit) }, + }, + yaxis: { + ...theme.yaxis, + title: { text: axisTitle(first.ylabel, first.yunit) }, + ...(range ? { range: [range[0], range[1]], autorange: false } : {}), + }, + }} + config={PLOT_CONFIG} + style={PLOT_STYLE} + useResizeHandler + /> + ); +} + +/** Live view of an instrument window: signals drawn together, or an image. */ +export default function InstrumentPlot({ frame, revision }: Props) { + if (frame.kind === "signals") { + return ; + } + const image = frame.items[0]; + return ( + + ); +} diff --git a/src/components/InstrumentWindow.tsx b/src/components/InstrumentWindow.tsx new file mode 100644 index 0000000..8460417 --- /dev/null +++ b/src/components/InstrumentWindow.tsx @@ -0,0 +1,245 @@ +import { + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { getRootIconUrl } from "../assets/rootIcons"; +import { t } from "../i18n/translate"; +import { useRuntime } from "../runtime/RuntimeContext"; +import type { + PluginInstrumentAcquisition, + PluginInstrumentFrame, + PluginInstrumentSession, +} from "../runtime/runtime"; +import { DataSetForm, stripTransientValues } from "./DataSetForm"; + +const InstrumentPlot = lazy(() => import("./InstrumentPlot")); + +/** Delay between a settings change and the refresh of the view. */ +const REFRESH_DELAY_MS = 150; + +interface Props { + pluginId: string; + toolId: string; + onAcquired: (result: PluginInstrumentAcquisition) => void | Promise; + onClose: () => void; +} + +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +/** + * Window of a plugin instrument: live view on the left, settings on the + * right. The view is refreshed when settings change, and periodically in + * live mode; each acquisition is added to the workspace in a new group. + */ +export function InstrumentWindow({ + pluginId, + toolId, + onAcquired, + onClose, +}: Props) { + const { runtime } = useRuntime(); + const [session, setSession] = useState(null); + const [values, setValues] = useState>({}); + const [valid, setValid] = useState(true); + const [frame, setFrame] = useState(null); + const [live, setLive] = useState(false); + const [acquiring, setAcquiring] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); + // One preview at a time; a request made meanwhile runs right after it. + const inFlightRef = useRef(false); + const pendingRef = useRef(false); + const valuesRef = useRef(values); + valuesRef.current = values; + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!runtime) return; + let cancelled = false; + runtime + .openPluginInstrument(pluginId, toolId) + .then((opened) => { + if (cancelled) return; + setSession(opened); + setValues(opened.settings.values); + }) + .catch((reason) => { + if (!cancelled) setError(errorMessage(reason)); + }); + return () => { + cancelled = true; + }; + }, [pluginId, runtime, toolId]); + + const settingsValues = useCallback( + () => + session + ? stripTransientValues(session.settings.schema, valuesRef.current) + : {}, + [session], + ); + + const refresh = useCallback(async () => { + if (!runtime || !session) return; + if (inFlightRef.current) { + pendingRef.current = true; + return; + } + inFlightRef.current = true; + try { + const next = await runtime.previewPluginInstrument( + pluginId, + toolId, + settingsValues(), + ); + if (!mountedRef.current) return; + setFrame(next); + setError(null); + } catch (reason) { + if (mountedRef.current) setError(errorMessage(reason)); + } finally { + inFlightRef.current = false; + if (pendingRef.current && mountedRef.current) { + pendingRef.current = false; + void refresh(); + } + } + }, [pluginId, runtime, session, settingsValues, toolId]); + + // Settings changes refresh the view once they settle. + useEffect(() => { + if (!session || !valid) return; + const timer = window.setTimeout(() => void refresh(), REFRESH_DELAY_MS); + return () => window.clearTimeout(timer); + }, [refresh, session, valid, values]); + + // Live mode: a new frame every period, skipped while one is computed. + useEffect(() => { + if (!live || !session || !valid) return; + const timer = window.setInterval(() => { + if (!inFlightRef.current) void refresh(); + }, session.live_interval_ms); + return () => window.clearInterval(timer); + }, [live, refresh, session, valid]); + + const acquire = async () => { + if (!runtime || !session) return; + setAcquiring(true); + setError(null); + setStatus(null); + try { + const result = await runtime.acquirePluginInstrument( + pluginId, + toolId, + settingsValues(), + ); + await onAcquired(result); + if (mountedRef.current) { + setStatus( + t("{count} objects added to group '{group}'", { + count: result.object_ids.length, + group: result.group_title, + }), + ); + } + } catch (reason) { + if (mountedRef.current) setError(errorMessage(reason)); + } finally { + if (mountedRef.current) setAcquiring(false); + } + }; + + const title = session?.title ?? t("Instrument"); + return ( +
+
+
+
+

{title}

+
+ +
+
+
+
+ {frame && ( + + + + )} +
+

{frame?.summary ?? ""}

+
+
+
+ {session && ( + + runtime!.resolvePluginInstrumentActive( + pluginId, + toolId, + current, + ) + } + onStateChange={(state) => setValid(state.valid)} + /> + )} +
+ {!valid &&
{t("Invalid settings")}
} + {error &&
{error}
} + {status &&
{status}
} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/locales/fr.json b/src/locales/fr.json index 0ffb8e2..3f7b6f7 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -26,6 +26,7 @@ "{count} notebook": "{count} notebook", "{count} notebooks": "{count} notebooks", "{count} object(s) will be written to the selected directory.": "{count} objet(s) seront écrits dans le répertoire sélectionné.", + "{count} objects added to group '{group}'": "{count} objets ajoutés au groupe « {group} »", "{count} warning": "{count} avertissement", "{count} warnings": "{count} avertissements", "{kind} (optional)": "{kind} (facultatif)", @@ -67,6 +68,8 @@ "About the online documentation.": "À propos de la documentation en ligne.", "Abscissa of the minimum and maximum": "Abscisse du minimum et du maximum", "Absolute value": "Valeur absolue", + "Acquire": "Acquérir", + "Acquiring…": "Acquisition…", "Adaptive histogram equalization": "Egalisation d'histogramme adaptative", "Adaptive histogram equalization…": "Egalisation d'histogramme adaptative…", "Add": "Ajouter", @@ -74,6 +77,7 @@ "Add a rectangle numerically": "Ajouter un rectangle numériquement", "Add a site": "Ajouter un site", "Add a vertex": "Ajouter un sommet", + "Add an acquisition to the workspace, in a new group": "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe", "Add circular ROI…": "Ajouter une ROI circulaire…", "Add column": "Ajouter une colonne", "Add constant": "Additionner une constante", @@ -542,6 +546,7 @@ "Install wheel…": "Installer un wheel…", "Installation and configuration": "Installation et configuration", "Installing Sigima…": "Installation de Sigima…", + "Instrument": "Instrument", "Integral": "Intégrale", "Intensity profiles": "Profils d'intensité", "Intensity rescaling": "Ajustement des niveaux", @@ -553,6 +558,7 @@ "Invalid application link: missing {parameters}.": "Lien d'application non valide : paramètres manquants : {parameters}.", "Invalid parameters": "Paramètres invalides", "Invalid pattern: {error}": "Motif invalide : {error}", + "Invalid settings": "Réglages non valides", "Inverse": "Inverse", "Inverse FFT": "FFT inverse", "Invert": "Inverser", @@ -579,6 +585,8 @@ "Linear calibration…": "Étalonnage linéaire…", "Linear fit": "Ajustement linéaire", "Lines": "Lignes", + "Live": "En direct", + "Live view": "Vue en direct", "llama.cpp server (local)": "Serveur llama.cpp (local)", "LM Studio (local)": "LM Studio (local)", "Load": "Charger", @@ -729,6 +737,7 @@ "Open them from each panel's Recent… menu. Signals and images are not cached in the browser — save an HDF5 workspace to make the current state durable.": "Ouvrez-les depuis le menu Récents… de chaque panneau. Les signaux et les images ne sont pas mis en cache dans le navigateur — enregistrez un espace de travail HDF5 pour rendre l'état actuel durable.", "Open this macro as a new notebook (cells split on # %% markers)": "Ouvrir cette macro comme un nouveau notebook (cellules séparées sur les marqueurs # %%)", "Open this notebook as a new DataLab macro": "Ouvrir ce notebook comme une nouvelle macro DataLab", + "Open tool": "Ouvrir l'outil", "Open…": "Ouvrir…", "OpenAI (cloud)": "OpenAI (cloud)", "OpenAI-compatible (network)": "Compatible OpenAI (réseau)", @@ -854,6 +863,7 @@ "rectangle": "rectangle", "Rectangle": "Rectangle", "Refresh": "Actualiser", + "Refresh the view continuously": "Rafraîchir la vue en continu", "Regions of interest": "Régions d'intérêt", "Reject": "Rejeter", "Release notes": "Notes de version", @@ -938,7 +948,16 @@ "Segment profile…": "Profil le long d'un segment…", "Select a node to preview…": "Sélectionnez un nœud pour l'aperçu…", "Select a node…": "Sélectionnez un nœud…", + "Select at least one image": "Sélectionnez au moins une image", "Select at least one image in the tree to display it here.": "Sélectionnez au moins une image dans l'arborescence pour l'afficher ici.", + "Select at least one object": "Sélectionnez au moins un objet", + "Select at least one signal": "Sélectionnez au moins un signal", + "Select at least two images": "Sélectionnez au moins deux images", + "Select at least two objects": "Sélectionnez au moins deux objets", + "Select at least two signals": "Sélectionnez au moins deux signaux", + "Select one image": "Sélectionnez une image", + "Select one object": "Sélectionnez un objet", + "Select one signal": "Sélectionnez un signal", "Select the input data in the workspace": "Sélectionner les données d'entrée dans l'espace de travail", "Selected signals are plotted here with Plotly.js. Use the curve context menu or the toolbar on the left of the plot to customise appearance — settings are saved in the signal metadata.": "Les signaux sélectionnés sont tracés ici avec Plotly.js. Utilisez le menu contextuel de la courbe ou la barre d'outils à gauche du graphique pour personnaliser l'apparence — les réglages sont enregistrés dans les métadonnées du signal.", "Send": "Envoyer", @@ -1071,6 +1090,7 @@ "to load an HDF5 file.": "pour charger un fichier HDF5.", "To set it, use Edit > Metadata > Add metadata…": "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées…", "Toolbar": "Barre d'outils", + "Tools ({count})": "Outils ({count})", "Total variance": "Variance totale", "Total variance…": "Variance totale…", "Total variation denoising": "Filtrage variationnel (débruitage)", diff --git a/src/runtime/builtin_wheels/datalab_camera_characterization-0.2.0-py3-none-any.whl b/src/runtime/builtin_wheels/datalab_camera_characterization-0.2.0-py3-none-any.whl index 13f20c3..19f909e 100644 Binary files a/src/runtime/builtin_wheels/datalab_camera_characterization-0.2.0-py3-none-any.whl and b/src/runtime/builtin_wheels/datalab_camera_characterization-0.2.0-py3-none-any.whl differ diff --git a/src/runtime/builtin_wheels/datalab_pulse_characterization-0.2.0-py3-none-any.whl b/src/runtime/builtin_wheels/datalab_pulse_characterization-0.2.0-py3-none-any.whl index 6395d62..353d9c7 100644 Binary files a/src/runtime/builtin_wheels/datalab_pulse_characterization-0.2.0-py3-none-any.whl and b/src/runtime/builtin_wheels/datalab_pulse_characterization-0.2.0-py3-none-any.whl differ diff --git a/src/runtime/bundledPlugins.ts b/src/runtime/bundledPlugins.ts index a54f92e..3e362f8 100644 --- a/src/runtime/bundledPlugins.ts +++ b/src/runtime/bundledPlugins.ts @@ -18,8 +18,8 @@ export const BUNDLED_PLUGIN_WHEELS: readonly BundledPluginWheel[] = version: "0.2.0", filename: "datalab_camera_characterization-0.2.0-py3-none-any.whl", sha256: - "b110475273e1daae9525912777d927cdba101f92646d70451ec0194481c5676c", - sizeBytes: 328_574, + "e89635561bd7b8eb10dd54f4f21ad4358d0b3562c7d016b8066ef1239b56a5c3", + sizeBytes: 332_561, url: cameraWheelUrl, }), Object.freeze({ @@ -27,8 +27,8 @@ export const BUNDLED_PLUGIN_WHEELS: readonly BundledPluginWheel[] = version: "0.2.0", filename: "datalab_pulse_characterization-0.2.0-py3-none-any.whl", sha256: - "84bf8efb051e47db9afe225b71b85bd95175718c2e2c3c048e5e275c3b9e812e", - sizeBytes: 75_123, + "317c1c1bb4dc32d270fa6f6586e830c0cea41dd3695bc70b42f4628d54466919", + sizeBytes: 82_275, url: pulseWheelUrl, }), ]); diff --git a/src/runtime/dlplugins/datalab/plugin_instruments.py b/src/runtime/dlplugins/datalab/plugin_instruments.py new file mode 100644 index 0000000..660391d --- /dev/null +++ b/src/runtime/dlplugins/datalab/plugin_instruments.py @@ -0,0 +1,115 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Instruments exposed by application plugins as tools. + +An instrument is a headless object: it holds settings (a guidata DataSet), +returns a live frame for these settings, and acquires objects. DataLab shows +it in a window with the live view on the left and the settings on the right. +DataLab Desktop and DataLab-Web share this module. +""" + +from __future__ import annotations + +import abc +import dataclasses +import math +from collections.abc import Sequence + +import guidata.dataset as gds +from sigima.objects import ImageObj, SignalObj + +__all__ = ["InstrumentAcquisition", "InstrumentFrame", "PluginInstrument"] + + +def _validate_objects(objects: Sequence[SignalObj | ImageObj], name: str) -> tuple: + """Return objects as a tuple of signals or of images.""" + objects = tuple(objects) + if not objects: + raise ValueError(f"{name} must contain at least one object") + if not ( + all(isinstance(obj, SignalObj) for obj in objects) + or all(isinstance(obj, ImageObj) for obj in objects) + ): + raise TypeError(f"{name} must contain signals only or images only") + return objects + + +@dataclasses.dataclass(frozen=True) +class InstrumentFrame: + """Live frame shown by DataLab for the current instrument settings. + + Args: + objects: signals drawn together, or a single image + summary: short text shown below the view (e.g. measured levels) + value_range: fixed Y range of signals, or color range of the image + (``None``: automatic) + """ + + objects: Sequence[SignalObj | ImageObj] + summary: str = "" + value_range: tuple[float, float] | None = None + + def __post_init__(self) -> None: + """Validate the frame contents.""" + objects = _validate_objects(self.objects, "Instrument frame") + if isinstance(objects[0], ImageObj) and len(objects) > 1: + raise ValueError("Instrument frame must contain a single image") + object.__setattr__(self, "objects", objects) + if not isinstance(self.summary, str): + raise TypeError("Instrument frame summary must be a string") + if self.value_range is not None: + low, high = (float(value) for value in self.value_range) + if not (math.isfinite(low) and math.isfinite(high) and low < high): + raise ValueError("Instrument frame range must be finite and increasing") + object.__setattr__(self, "value_range", (low, high)) + + +@dataclasses.dataclass(frozen=True) +class InstrumentAcquisition: + """Objects acquired by an instrument, added by DataLab in a new group. + + Args: + group_title: title of the group created for the acquisition + objects: acquired signals, or acquired images + """ + + group_title: str + objects: Sequence[SignalObj | ImageObj] + + def __post_init__(self) -> None: + """Validate the acquisition contents.""" + if not isinstance(self.group_title, str) or not self.group_title.strip(): + raise ValueError("Instrument acquisition group title must not be empty") + object.__setattr__( + self, "objects", _validate_objects(self.objects, "Instrument acquisition") + ) + + +class PluginInstrument(abc.ABC): + """Instrument opened by a plugin tool. + + DataLab writes the edited values into :attr:`settings` before calling + :meth:`preview` or :meth:`acquire`. Both methods raise ``ValueError`` + with a user-facing message when the settings cannot be used. The plugin + keeps one instance per tool during the session, so settings are kept + from one opening to the next. + + Args: + settings: instrument settings, edited by DataLab + """ + + #: Interval between two frames in live mode, in milliseconds + live_interval_ms: int = 500 + + def __init__(self, settings: gds.DataSet) -> None: + if not isinstance(settings, gds.DataSet): + raise TypeError("Instrument settings must be a guidata DataSet") + self.settings = settings + + @abc.abstractmethod + def preview(self) -> InstrumentFrame: + """Return a live frame for the current settings.""" + + @abc.abstractmethod + def acquire(self) -> InstrumentAcquisition: + """Acquire objects with the current settings.""" diff --git a/src/runtime/dlplugins/datalab/plugin_resources.py b/src/runtime/dlplugins/datalab/plugin_resources.py new file mode 100644 index 0000000..cc5acb0 --- /dev/null +++ b/src/runtime/dlplugins/datalab/plugin_resources.py @@ -0,0 +1,80 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Package resources declared by plugins with the ``package:path`` syntax.""" + +from __future__ import annotations + +import re +import sys +from importlib import resources +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + if sys.version_info >= (3, 11): + from importlib.resources.abc import Traversable + else: + from importlib.abc import Traversable + +__all__ = ["LOCAL_ID_PATTERN", "resolve_package_resource", "split_package_resource"] + + +#: Plugin-local identifier syntax (examples, welcome page tiles) +LOCAL_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") +_PACKAGE_PATTERN = re.compile( + r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$", + flags=re.ASCII, +) + + +def split_package_resource( + resource: str, label: str = "Plugin resource" +) -> tuple[str, str]: + """Validate a ``package:path`` resource and return its two parts + + Args: + resource: resource declaration, e.g. ``"my_plugin:icons/logo.svg"`` + label: resource description used in error messages + + Returns: + Importable package name and POSIX path relative to this package + + Raises: + ValueError: if the declaration is malformed + """ + if not isinstance(resource, str) or resource.count(":") != 1: + raise ValueError(f"{label} must use 'package:path' syntax") + package, path = resource.split(":") + if not _PACKAGE_PATTERN.fullmatch(package): + raise ValueError(f"{label} package is invalid") + posix_path = PurePosixPath(path) + if not path or "\\" in path or posix_path.is_absolute() or ".." in posix_path.parts: + raise ValueError(f"{label} path must be relative") + return package, path + + +def resolve_package_resource( + resource: str, label: str = "Plugin resource" +) -> Traversable: + """Resolve a ``package:path`` resource without requiring a filesystem path + + Args: + resource: resource declaration, e.g. ``"my_plugin:icons/logo.svg"`` + label: resource description used in error messages + + Returns: + ``importlib.resources`` traversable, also valid for zipped packages + + Raises: + ValueError: if the declaration is malformed + FileNotFoundError: if the package does not contain the resource + """ + package, path = split_package_resource(resource, label) + traversable = resources.files(package) + for part in PurePosixPath(path).parts: + traversable = traversable.joinpath(part) + # Python 3.9's zipfile.Path.is_file() is also True for missing entries + missing = sys.version_info < (3, 10) and not traversable.exists() + if missing or not traversable.is_file(): + raise FileNotFoundError(f"{label} not found: {resource}") + return traversable diff --git a/src/runtime/dlplugins/datalab/plugin_tools.py b/src/runtime/dlplugins/datalab/plugin_tools.py new file mode 100644 index 0000000..718d7de --- /dev/null +++ b/src/runtime/dlplugins/datalab/plugin_tools.py @@ -0,0 +1,121 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Declarations of plugin-owned tools listed in the Applications catalog. + +DataLab Desktop and DataLab-Web share this module. +""" + +from __future__ import annotations + +import dataclasses +import enum +from collections.abc import Sequence + +from sigima.objects import ImageObj, SignalObj + +from datalab.plugin_resources import LOCAL_ID_PATTERN, split_package_resource +from datalab.recipes import RecipeObjectType + +__all__ = ["PluginTool", "ToolSelection", "tool_accepts_selection"] + + +class ToolSelection(str, enum.Enum): + """Selection a tool needs to be opened (names follow DataLab's conditions).""" + + NONE = "none" + EXACTLY_ONE = "exactly_one" + AT_LEAST_ONE = "at_least_one" + AT_LEAST_TWO = "at_least_two" + + +@dataclasses.dataclass(frozen=True) +class PluginTool: + """Tool with its own user interface, exposed by an application plugin + + Recipes cover headless analyses run by DataLab; a tool covers any other + interaction owned by the plugin (wizard, interactive editor, data + preparation, instrument...). DataLab lists tools in the Applications + catalog and in the plugin's submenu of the *Plugins* menu. + + A tool is opened either by a ``launcher`` method, called without + arguments, or by an ``instrument`` method returning a + :class:`datalab.plugin_instruments.PluginInstrument`, which DataLab shows + in a window with a live view and the instrument settings. + + Args: + id: plugin-local tool ID (lowercase letters, digits, ``.``, ``_``, ``-``) + title: tool title + launcher: name of the plugin method called, without arguments, to open + the tool + description: short description of what the tool does + icon: ``package:path`` resource (SVG or bitmap) or DataLab icon file + name; ``None`` falls back to the plugin icon + instrument: name of the plugin method returning the instrument shown + by DataLab (exclusive with ``launcher``) + object_type: type of the objects the tool works on or creates; the + tool is listed in the menu of that panel (``None``: both panels) + selection: selection needed to open the tool, counting objects of + ``object_type`` only + """ + + id: str + title: str + launcher: str | None = None + description: str = "" + icon: str | None = None + instrument: str | None = None + object_type: RecipeObjectType | None = None + selection: ToolSelection = ToolSelection.NONE + + def __post_init__(self) -> None: + """Validate identity, opening method, selection, and icon resource.""" + if not isinstance(self.id, str) or not LOCAL_ID_PATTERN.fullmatch(self.id): + raise ValueError( + "Plugin tool ID must contain lowercase letters, digits, '.', '_' or '-'" + ) + if not isinstance(self.title, str) or not self.title.strip(): + raise ValueError("Plugin tool title must be a non-empty string") + if (self.launcher is None) == (self.instrument is None): + raise ValueError("Plugin tool needs either a launcher or an instrument") + for method_name in (self.launcher, self.instrument): + if method_name is not None and ( + not isinstance(method_name, str) or not method_name.isidentifier() + ): + raise ValueError("Plugin tool launcher must be a method name") + if not isinstance(self.description, str): + raise TypeError("Plugin tool description must be a string") + if self.icon is not None: + if not isinstance(self.icon, str) or not self.icon.strip(): + raise ValueError("Plugin tool icon must be a non-empty string") + if ":" in self.icon: + split_package_resource(self.icon, "Plugin tool icon") + if self.object_type is not None: + object.__setattr__(self, "object_type", RecipeObjectType(self.object_type)) + object.__setattr__(self, "selection", ToolSelection(self.selection)) + + +def tool_accepts_selection( + tool: PluginTool, objects: Sequence[SignalObj | ImageObj] +) -> bool: + """Return True if the selected objects allow opening the tool. + + Args: + tool: tool to open + objects: selected objects + + Returns: + True if the number of selected objects of the tool's object type + matches the tool's selection + """ + object_class = { + RecipeObjectType.SIGNAL: SignalObj, + RecipeObjectType.IMAGE: ImageObj, + None: (SignalObj, ImageObj), + }[tool.object_type] + count = sum(isinstance(obj, object_class) for obj in objects) + return { + ToolSelection.NONE: True, + ToolSelection.EXACTLY_ONE: count == 1, + ToolSelection.AT_LEAST_ONE: count >= 1, + ToolSelection.AT_LEAST_TWO: count >= 2, + }[tool.selection] diff --git a/src/runtime/dlplugins/datalab/plugins.py b/src/runtime/dlplugins/datalab/plugins.py index ceae28f..ec38415 100644 --- a/src/runtime/dlplugins/datalab/plugins.py +++ b/src/runtime/dlplugins/datalab/plugins.py @@ -37,7 +37,7 @@ import os.path as osp import sys import traceback -from collections.abc import Collection +from collections.abc import Collection, Sequence from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -53,10 +53,12 @@ from datalab.control.proxy import LocalProxy from datalab.env import execenv from datalab.plugin_examples import PluginExample, PluginExampleData +from datalab.plugin_instruments import PluginInstrument +from datalab.plugin_tools import PluginTool, ToolSelection, tool_accepts_selection from datalab.recipes import RecipeDescriptor if TYPE_CHECKING: - from sigima.objects import NewImageParam, NewSignalParam + from sigima.objects import ImageObj, NewImageParam, NewSignalParam, SignalObj from datalab.gui import main from datalab.gui.panel.image import ImagePanel @@ -249,6 +251,23 @@ def __post_init__(self) -> None: raise ValueError("Plugin documentation URL must use HTTP or HTTPS") +def format_tool_requirement(tool: PluginTool) -> str: + """Return the message telling which objects to select to open a tool.""" + object_type = None if tool.object_type is None else tool.object_type.value + messages = { + (ToolSelection.EXACTLY_ONE, "signal"): _("Select one signal"), + (ToolSelection.EXACTLY_ONE, "image"): _("Select one image"), + (ToolSelection.EXACTLY_ONE, None): _("Select one object"), + (ToolSelection.AT_LEAST_ONE, "signal"): _("Select at least one signal"), + (ToolSelection.AT_LEAST_ONE, "image"): _("Select at least one image"), + (ToolSelection.AT_LEAST_ONE, None): _("Select at least one object"), + (ToolSelection.AT_LEAST_TWO, "signal"): _("Select at least two signals"), + (ToolSelection.AT_LEAST_TWO, "image"): _("Select at least two images"), + (ToolSelection.AT_LEAST_TWO, None): _("Select at least two objects"), + } + return messages.get((tool.selection, object_type), "") + + class PluginBaseMeta(PluginRegistry, abc.ABCMeta): """Mixed metaclass to avoid metaclass conflicts.""" @@ -259,11 +278,15 @@ class PluginBase(abc.ABC, metaclass=PluginBaseMeta): PLUGIN_INFO: PluginInfo = None RECIPES: tuple[RecipeDescriptor, ...] = () EXAMPLES: tuple[PluginExample, ...] = () + TOOLS: tuple[PluginTool, ...] = () def __init__(self): self.main: main.DLMainWindow = None self.proxy: LocalProxy = None self._is_registered = False + self._instruments: dict[str, PluginInstrument] = {} + #: Objects selected when the running tool was launched + self.selected_objects: tuple[SignalObj | ImageObj, ...] = () self.info = self.PLUGIN_INFO if self.info is None: raise ValueError(f"Plugin info not set for {self.__class__.__name__}") @@ -350,6 +373,91 @@ def materialize_example(cls, example_id: str) -> PluginExampleData | None: cls.get_example(example_id) return None + # -- Tools --------------------------------------------------------- + + @classmethod + def get_tools(cls) -> tuple[PluginTool, ...]: + """Return validated tools listed in the Applications catalog.""" + tools = tuple(cls.TOOLS) + if not tools: + return () + info = cls.PLUGIN_INFO + if PluginCapability.APPLICATION not in info.capabilities: + raise ValueError("Plugin tools require the APPLICATION capability") + if not all(isinstance(tool, PluginTool) for tool in tools): + raise TypeError("Plugin tools must be PluginTool values") + tool_ids: set[str] = set() + for tool in tools: + if tool.id in tool_ids: + raise ValueError(f"Duplicate plugin tool ID: {tool.id!r}") + method_name = tool.launcher or tool.instrument + if not callable(getattr(cls, method_name, None)): + kind = "launcher" if tool.launcher else "instrument" + raise ValueError( + f"Plugin tool {kind} method {method_name!r} is not callable" + ) + tool_ids.add(tool.id) + return tools + + @classmethod + def get_tool(cls, tool_id: str) -> PluginTool: + """Return one tool by its plugin-local ID.""" + for tool in cls.get_tools(): + if tool.id == tool_id: + return tool + raise KeyError(f"Plugin tool {tool_id!r} not found") + + def get_selected_objects(self) -> list[SignalObj | ImageObj]: + """Return the objects selected when the running tool was launched.""" + return list(self.selected_objects) + + def assess_tool( + self, + tool_id: str, + objects: Sequence[SignalObj | ImageObj] | None = None, + ) -> str | None: + """Return why a tool cannot be opened on objects (default: selection).""" + tool = self.get_tool(tool_id) + if tool.selection is ToolSelection.NONE: + return None + if objects is None: + objects = self.get_selected_objects() + if tool_accepts_selection(tool, objects): + return None + return format_tool_requirement(tool) + + def launch_tool(self, tool_id: str) -> object: + """Call the launcher of a plugin tool; the host awaits a coroutine. + + Raises: + ValueError: if the selection does not allow opening the tool, or + if the tool is an instrument (opened by the host) + """ + if self.main is None: + raise RuntimeError("Plugin must be registered before launching a tool") + tool = self.get_tool(tool_id) + issue = self.assess_tool(tool_id) + if issue is not None: + raise ValueError(issue) + if tool.launcher is None: + raise ValueError(f"Plugin tool {tool_id!r} is an instrument") + return getattr(self, tool.launcher)() + + def get_instrument(self, tool_id: str) -> PluginInstrument: + """Return the instrument of a tool, created once per registration.""" + tool = self.get_tool(tool_id) + if tool.instrument is None: + raise ValueError(f"Plugin tool {tool_id!r} has no instrument") + if tool_id not in self._instruments: + instrument = getattr(self, tool.instrument)() + if not isinstance(instrument, PluginInstrument): + raise TypeError( + f"Plugin tool instrument method {tool.instrument!r} must " + "return a PluginInstrument" + ) + self._instruments[tool_id] = instrument + return self._instruments[tool_id] + # -- Convenience accessors ----------------------------------------- @property @@ -540,6 +648,7 @@ def unregister(self) -> None: registries.clear_origin(self.plugin_id) self.main = None self.proxy = None + self._instruments.clear() def register_hooks(self) -> None: """Override to register additional hooks at plugin load time.""" diff --git a/src/runtime/dlw_applications.py b/src/runtime/dlw_applications.py index 7b74ccb..f95bc6d 100644 --- a/src/runtime/dlw_applications.py +++ b/src/runtime/dlw_applications.py @@ -5,6 +5,7 @@ from __future__ import annotations import dataclasses +import inspect import json import os from collections.abc import Callable, Mapping, Sequence @@ -81,6 +82,8 @@ class RecipeCommitError(RuntimeError): _WORKSPACE_LOADER: WorkspaceLoader | None = globals().get("_WORKSPACE_LOADER") _RESET_WORKSPACE: Callable[[], None] | None = globals().get("_RESET_WORKSPACE") _DATALAB_WEB_VERSION: str = globals().get("_DATALAB_WEB_VERSION", "0.0.0") +_SIGNAL_PAYLOAD: Callable[..., dict[str, Any]] | None = globals().get("_SIGNAL_PAYLOAD") +_IMAGE_PAYLOAD: Callable[..., dict[str, Any]] | None = globals().get("_IMAGE_PAYLOAD") def install_host( @@ -89,10 +92,16 @@ def install_host( workspace_loader: WorkspaceLoader, datalab_web_version: str, reset_workspace: Callable[[], None] | None = None, + signal_payload: Callable[..., dict[str, Any]] | None = None, + image_payload: Callable[..., dict[str, Any]] | None = None, ) -> None: - """Install the live model, UUID accessor, workspace loader, and version.""" + """Install the live model, accessors, workspace loader, and version. + + ``signal_payload`` and ``image_payload`` encode the transient objects + shown by instrument windows, like the plots of workspace objects. + """ global _DATALAB_WEB_VERSION, _MODEL, _OBJECT_UUID, _RESET_WORKSPACE - global _WORKSPACE_LOADER + global _WORKSPACE_LOADER, _SIGNAL_PAYLOAD, _IMAGE_PAYLOAD required_methods = ( "has", "get", @@ -114,6 +123,8 @@ def install_host( _WORKSPACE_LOADER = workspace_loader _RESET_WORKSPACE = reset_workspace _DATALAB_WEB_VERSION = datalab_web_version + _SIGNAL_PAYLOAD = signal_payload + _IMAGE_PAYLOAD = image_payload def _require_host() -> tuple[ApplicationModel, Callable[[Any], str | None]]: @@ -781,14 +792,166 @@ def open_plugin_example( } +def _object_ids(model: ApplicationModel) -> dict[str, list[str]]: + """Return the IDs of the objects of each panel.""" + return { + kind: [ + obj["id"] + for group in model.panel_tree(kind)["groups"] + for obj in group["objects"] + ] + for kind in ("signal", "image") + } + + +async def launch_plugin_tool( + plugin_id: str, + tool_id: str, + selected_ids: Sequence[str] = (), +) -> dict[str, list[str]]: + """Run the launcher of a plugin tool on the selected objects. + + Args: + plugin_id: Plugin owning the tool. + tool_id: Tool to open. + selected_ids: Objects selected in the active panel. + + Returns: + IDs of the objects added by the tool, per panel. + """ + model, _object_uuid = _require_host() + plugin = dlw_plugins.get_plugin_instance(plugin_id) + objects = tuple(obj for _oid, obj in _candidates(selected_ids)) + before = _object_ids(model) + plugin.selected_objects = objects + try: + result = plugin.launch_tool(tool_id) + if inspect.isawaitable(result): + await result + finally: + plugin.selected_objects = () + after = _object_ids(model) + return { + kind: [oid for oid in ids if oid not in set(before[kind])] + for kind, ids in after.items() + } + + +def _instrument( + plugin_id: str, tool_id: str, values: Mapping[str, object] | None = None +) -> Any: + """Return a tool's instrument, after writing edited settings into it.""" + instrument = dlw_plugins.get_plugin_instance(plugin_id).get_instrument(tool_id) + normalized = _normalize_mapping(values, "Instrument settings") + if normalized: + from guidata.dataset import update_dataset + + update_dataset(instrument.settings, normalized) + return instrument + + +def open_plugin_instrument(plugin_id: str, tool_id: str) -> dict[str, Any]: + """Return what an instrument window shows: title, settings, live period.""" + from guidata.dataset import dataset_to_schema_with_values + + plugin = dlw_plugins.get_plugin_instance(plugin_id) + tool = plugin.get_tool(tool_id) + instrument = plugin.get_instrument(tool_id) + title = tool.title.rstrip(".…") + settings = dataset_to_schema_with_values(instrument.settings) + settings["title"] = title + return { + "plugin_id": plugin_id, + "tool_id": tool_id, + "title": title, + "settings": settings, + "live_interval_ms": int(instrument.live_interval_ms), + } + + +def preview_plugin_instrument( + plugin_id: str, + tool_id: str, + values: Mapping[str, object] | None = None, +) -> dict[str, Any]: + """Return a live frame of an instrument for edited settings.""" + if _SIGNAL_PAYLOAD is None or _IMAGE_PAYLOAD is None: + raise RuntimeError("Instrument frame encoders are not installed") + frame = _instrument(plugin_id, tool_id, values).preview() + first = frame.objects[0] + if isinstance(first, ImageObj): + kind = "image" + items = [_IMAGE_PAYLOAD(first, "instrument", encoding="bytes")] + else: + kind = "signals" + items = [ + _SIGNAL_PAYLOAD(obj, f"instrument-{index}", encoding="bytes") + for index, obj in enumerate(frame.objects) + ] + return { + "kind": kind, + "items": items, + "summary": frame.summary, + "value_range": None if frame.value_range is None else list(frame.value_range), + } + + +def acquire_plugin_instrument( + plugin_id: str, + tool_id: str, + values: Mapping[str, object] | None = None, +) -> dict[str, Any]: + """Add an instrument acquisition to the workspace, in a new group.""" + model, _object_uuid = _require_host() + acquisition = _instrument(plugin_id, tool_id, values).acquire() + kind = _output_kind(acquisition.objects[0]) + group_id = model.create_group(kind, acquisition.group_title) + added: list[str] = [] + try: + for obj in acquisition.objects: + added.append(model.add_object(kind, obj, group_id)) + except Exception as exc: + for oid in reversed(added): + model.delete_object(oid) + model.delete_group(kind, group_id) + raise RecipeCommitError(str(exc)) from exc + return { + "panel": kind, + "group_id": group_id, + "group_title": acquisition.group_title, + "object_ids": added, + } + + +def resolve_plugin_instrument_active( + plugin_id: str, + tool_id: str, + values: Mapping[str, object] | None = None, +) -> dict[str, bool]: + """Evaluate guidata display-active rules for edited instrument settings.""" + from guidata.dataset import resolve_dataset_active, update_dataset + + instrument = dlw_plugins.get_plugin_instance(plugin_id).get_instrument(tool_id) + settings = instrument.settings.__class__() + normalized = _normalize_mapping(values, "Instrument settings") + if normalized: + update_dataset(settings, normalized) + return resolve_dataset_active(settings) + + __all__ = [ "RecipeCommitError", + "acquire_plugin_instrument", "assess_plugin_recipes", "check_plugin_recipe_bindings", "get_plugin_recipe_schema", "install_host", + "launch_plugin_tool", "open_plugin_example", + "open_plugin_instrument", "prepare_plugin_recipe", + "preview_plugin_instrument", + "resolve_plugin_instrument_active", "resolve_plugin_recipe_active", "resolve_plugin_recipe_callbacks", "resolve_plugin_recipe_choices", diff --git a/src/runtime/dlw_plugins.py b/src/runtime/dlw_plugins.py index eecf163..f16ab82 100644 --- a/src/runtime/dlw_plugins.py +++ b/src/runtime/dlw_plugins.py @@ -173,6 +173,7 @@ def _activate_plugin_class(record: PluginRecord, plugin_cls: type[PluginBase]) - raise ValueError(f"Plugin ID {plugin_id!r} already registered") plugin_cls.get_recipes() plugin_cls.get_examples() + plugin_cls.get_tools() instance = plugin_cls() try: if _MAIN is not None: @@ -630,6 +631,21 @@ def get_plugin_class( return record.classes[0] +def get_plugin_instance(plugin_id: str) -> PluginBase: + """Return the registered instance of an enabled plugin. + + Raises: + KeyError: If no managed plugin has this identifier. + RuntimeError: If the plugin is disabled or failed to load. + """ + record = _RECORDS.get(plugin_id) + if record is None or record.plugin_id != plugin_id: + raise KeyError(f"Unknown plugin {plugin_id!r}") + if not record.enabled or record.instance is None: + raise RuntimeError(f"Plugin {plugin_id!r} is disabled") + return record.instance + + def declared_metadata_keys(object_type: str) -> list[tuple[str, str]]: """Return the metadata keys expected by the methods of active applications. @@ -711,6 +727,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]: plugin_cls = record.classes[0] recipes = [] examples = [] + tools = [] if plugin_cls is not None: recipes = [ { @@ -733,6 +750,19 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]: } for example in plugin_cls.get_examples() ] + tools = [ + { + "id": tool.id, + "title": tool.title, + "description": tool.description, + "kind": "launcher" if tool.launcher is not None else "instrument", + "object_type": ( + None if tool.object_type is None else tool.object_type.value + ), + "selection": tool.selection.value, + } + for tool in plugin_cls.get_tools() + ] return { "name": record.name, "record_id": record.name, @@ -753,6 +783,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]: "info": info, "recipes": recipes, "examples": examples, + "tools": tools, "operations": { "can_enable": bool(record.classes), "can_disable": record.instance is not None, @@ -769,6 +800,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]: "declared_metadata_keys", "discover_plugins_in_dir", "get_plugin_class", + "get_plugin_instance", "inspect_plugin_wheel", "install_main", "list_plugins", diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 9b173b8..4cbb131 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -302,6 +302,50 @@ export interface PluginExampleSummary { expected_checks: string[]; } +/** Selection a plugin tool needs (names follow menu select conditions). */ +export type PluginToolSelection = + | "none" + | "exactly_one" + | "at_least_one" + | "at_least_two"; + +export interface PluginToolSummary { + id: string; + title: string; + description: string; + /** ``launcher``: plugin code; ``instrument``: window drawn by DataLab. */ + kind: "launcher" | "instrument"; + /** Panel of the objects the tool works on; ``null`` for both panels. */ + object_type: PanelKind | null; + selection: PluginToolSelection; +} + +/** What an instrument window shows when it opens. */ +export interface PluginInstrumentSession { + plugin_id: string; + tool_id: string; + title: string; + settings: SchemaWithValues; + live_interval_ms: number; +} + +/** Live frame of an instrument: signals drawn together, or one image. */ +export type PluginInstrumentFrame = ( + | { kind: "signals"; items: SignalData[] } + | { kind: "image"; items: ImageData[] } +) & { + summary: string; + /** Fixed Y range of signals, or color range of the image. */ + value_range: [number, number] | null; +}; + +export interface PluginInstrumentAcquisition { + panel: PanelKind; + group_id: string; + group_title: string; + object_ids: string[]; +} + export interface PluginWheelInspection { filename: string; distribution: string; @@ -344,6 +388,7 @@ export interface PluginRecord { info: PluginInfoMeta | null; recipes: PluginRecipeSummary[]; examples: PluginExampleSummary[]; + tools: PluginToolSummary[]; operations: { can_enable: boolean; can_disable: boolean; @@ -1371,6 +1416,8 @@ export class DataLabRuntime { "run_signal_analysis", "run_image_analysis", "run_plugin_recipe", + "launch_plugin_tool", + "acquire_plugin_instrument", "open_signal_from_bytes", "open_image_from_bytes", "open_from_directory_chunk", @@ -1601,13 +1648,20 @@ await micropip.install([${JSON.stringify(SIGIMA_INSTALL_SPEC)}, ${JSON.stringify open_workspace_from_bytes, ${JSON.stringify(import.meta.env.VITE_APP_VERSION)}, reset_all, + signal_payload=_signal_data_payload, + image_payload=_image_data_payload, ) from dlw_applications import ( + acquire_plugin_instrument, assess_plugin_recipes, check_plugin_recipe_bindings, get_plugin_recipe_schema, + launch_plugin_tool, open_plugin_example, + open_plugin_instrument, prepare_plugin_recipe, + preview_plugin_instrument, + resolve_plugin_instrument_active, resolve_plugin_recipe_active, resolve_plugin_recipe_callbacks, resolve_plugin_recipe_choices, @@ -3178,6 +3232,98 @@ await micropip.install([${JSON.stringify(SIGIMA_INSTALL_SPEC)}, ${JSON.stringify })) as PluginExampleOpenResult; } + /** Run the launcher of a plugin tool on the selected objects. + * + * Returns the IDs of the objects the tool added, per panel. */ + async launchPluginTool( + pluginId: string, + toolId: string, + selectedIds: string[], + ): Promise<{ signal: string[]; image: string[] }> { + return (await this.callPy("launch_plugin_tool", { + plugin_id: pluginId, + tool_id: toolId, + selected_ids: selectedIds, + })) as { signal: string[]; image: string[] }; + } + + /** Return the title, settings and live period of a plugin instrument. */ + async openPluginInstrument( + pluginId: string, + toolId: string, + ): Promise { + return (await this.callPy("open_plugin_instrument", { + plugin_id: pluginId, + tool_id: toolId, + })) as PluginInstrumentSession; + } + + /** Return a live frame of a plugin instrument for edited settings. */ + async previewPluginInstrument( + pluginId: string, + toolId: string, + values: Record, + ): Promise { + const raw = (await this.callPy("preview_plugin_instrument", { + plugin_id: pluginId, + tool_id: toolId, + values, + })) as { + kind: "signals" | "image"; + items: Array>; + summary: string; + value_range: [number, number] | null; + }; + const common = { summary: raw.summary, value_range: raw.value_range }; + if (raw.kind === "image") { + return { + ...common, + kind: "image", + items: raw.items.map( + (item) => + decodeImagePayload( + item as unknown as ImageData & { encoding?: string }, + ) as ImageData, + ), + }; + } + return { + ...common, + kind: "signals", + items: raw.items.map((item) => + decodeSignalPayload( + item as unknown as SignalData & { encoding?: string }, + ), + ), + }; + } + + /** Add an acquisition of a plugin instrument to the workspace. */ + async acquirePluginInstrument( + pluginId: string, + toolId: string, + values: Record, + ): Promise { + return (await this.callPy("acquire_plugin_instrument", { + plugin_id: pluginId, + tool_id: toolId, + values, + })) as PluginInstrumentAcquisition; + } + + /** Resolve which instrument settings are active for edited values. */ + async resolvePluginInstrumentActive( + pluginId: string, + toolId: string, + values: Record, + ): Promise> { + return (await this.callPy("resolve_plugin_instrument_active", { + plugin_id: pluginId, + tool_id: toolId, + values, + })) as Record; + } + // ------------------------------------------------------------------ // Foreign HDF5 browser (Phase 2) // ------------------------------------------------------------------ diff --git a/src/styles.css b/src/styles.css index 702284f..6895bbc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3455,6 +3455,110 @@ input { flex: 0 0 auto; } +/* Plugin instrument: live view on the left, settings on the right. */ +.instrument-window-layer { + position: fixed; + top: 64px; + left: 16px; + bottom: 16px; + z-index: 10; + display: flex; + align-items: flex-start; + pointer-events: none; +} + +.instrument-window { + display: flex; + flex-direction: column; + width: min(1040px, calc(100vw - 32px)); + height: min(640px, 100%); + overflow: hidden; + padding: 0; + background: var(--panel); + border: 1px solid var(--border); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + pointer-events: auto; +} + +.instrument-window-body { + display: grid; + flex: 1; + grid-template-columns: minmax(0, 1.4fr) minmax(300px, 1fr); + min-height: 0; +} + +.instrument-view { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-width: 0; + padding: 12px; +} + +.instrument-plot { + min-height: 0; +} + +.instrument-summary { + min-height: 18px; + margin: 6px 0 0; + color: var(--text-dim); + font-size: 12px; +} + +.instrument-settings { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + min-height: 0; + padding: 12px; + border-left: 1px solid var(--border); +} + +.instrument-form { + flex: 1; + min-height: 0; + overflow: auto; +} + +.instrument-commands { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.instrument-commands button { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.instrument-commands button img { + width: 16px; + height: 16px; +} + +.instrument-commands button.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 16%, var(--panel)); +} + +@media (max-width: 760px) { + .instrument-window-body { + grid-template-columns: minmax(0, 1fr); + overflow: auto; + } + + .instrument-view { + min-height: 280px; + } + + .instrument-settings { + border-left: 0; + border-top: 1px solid var(--border); + } +} + .application-doc-button { margin-top: 18px; } diff --git a/src/utils/pluginTools.ts b/src/utils/pluginTools.ts new file mode 100644 index 0000000..28cbb10 --- /dev/null +++ b/src/utils/pluginTools.ts @@ -0,0 +1,45 @@ +import { t } from "../i18n/translate"; +import type { PanelKind, PluginToolSummary } from "../runtime/runtime"; + +/** Return the message telling which objects to select to open a tool. */ +function toolRequirement(tool: PluginToolSummary): string { + const kind = tool.object_type; + switch (tool.selection) { + case "exactly_one": + if (kind === "signal") return t("Select one signal"); + if (kind === "image") return t("Select one image"); + return t("Select one object"); + case "at_least_one": + if (kind === "signal") return t("Select at least one signal"); + if (kind === "image") return t("Select at least one image"); + return t("Select at least one object"); + default: + if (kind === "signal") return t("Select at least two signals"); + if (kind === "image") return t("Select at least two images"); + return t("Select at least two objects"); + } +} + +/** + * Return why a plugin tool cannot be opened on the selection, or ``null``. + * + * Only the objects of the tool's type count: a selection in the other + * panel does not open an image tool, for instance. + */ +export function toolSelectionIssue( + tool: PluginToolSummary, + activePanel: PanelKind, + selectedCount: number, +): string | null { + const count = + tool.object_type !== null && tool.object_type !== activePanel + ? 0 + : selectedCount; + const accepted = { + none: true, + exactly_one: count === 1, + at_least_one: count >= 1, + at_least_two: count >= 2, + }[tool.selection]; + return accepted ? null : toolRequirement(tool); +} diff --git a/tests/e2e/instrument_tools.spec.ts b/tests/e2e/instrument_tools.spec.ts new file mode 100644 index 0000000..d78b03d --- /dev/null +++ b/tests/e2e/instrument_tools.spec.ts @@ -0,0 +1,152 @@ +import { expect, type Page } from "@playwright/test"; +import { dismissAnyDialog, test } from "./fixtures-warm"; + +// Instrument tools of the bundled applications: the window drawn by DataLab +// shows a live view, greys out settings, and acquires objects ready for the +// application methods. + +async function openPluginTool(page: Page, application: string, tool: string) { + await page.getByRole("menuitem", { name: "Plugins", exact: true }).click(); + await page + .locator(".menu-item-submenu") + .filter({ hasText: application }) + .hover(); + await page.getByRole("menuitem", { name: tool }).click(); +} + +function formRow(page: Page, label: string) { + return page + .locator(".instrument-window .dataset-form-row") + .filter({ has: page.locator(".dataset-form-label", { hasText: label }) }); +} + +async function expectMethodReady( + page: Page, + application: string, + recipeId: string, +) { + await page.getByRole("menuitem", { name: "Applications…" }).click(); + const applications = page.getByRole("dialog", { name: "Applications" }); + await expect(applications).toBeVisible(); + await applications + .locator(".applications-list button") + .filter({ hasText: application }) + .click(); + await expect( + applications.locator(`[data-recipe-id="${recipeId}"] [data-readiness]`), + ).toHaveAttribute("data-readiness", "ready", { timeout: 30_000 }); + await expect(applications.locator(".application-tools summary")).toHaveText( + "Tools (1)", + ); + await applications.getByRole("button", { name: "Close" }).click(); +} + +test("Oscilloscope simulator acquires delayed pulse pairs", async ({ + warmPage: page, +}) => { + test.setTimeout(240_000); + await dismissAnyDialog(page); + await page.evaluate(() => window.runtime.resetAll()); + await page.getByRole("tab", { name: "Signals" }).click(); + + await openPluginTool( + page, + "Pulse & Transient Characterization", + "Oscilloscope simulator", + ); + const instrument = page.getByRole("dialog", { + name: "Oscilloscope simulator", + }); + await expect(instrument).toBeVisible(); + const traces = instrument.locator(".instrument-plot .scatterlayer .trace"); + await expect(traces).toHaveCount(1, { timeout: 60_000 }); + await expect(instrument.locator(".instrument-summary")).toContainText( + "CH1: peak", + ); + + await formRow(page, "Source").locator("select").selectOption("pulse-pair"); + await expect(traces).toHaveCount(2, { timeout: 30_000 }); + await expect(instrument.locator(".instrument-summary")).toContainText( + "CH2: peak", + ); + await expect(formRow(page, "Detector delay").locator("input")).toBeEnabled({ + timeout: 30_000, + }); + await expect( + formRow(page, "Natural frequency").locator("input"), + ).toBeDisabled(); + + await instrument + .locator(".dataset-form-tab-bar button", { hasText: "Oscilloscope" }) + .click(); + await formRow(page, "Triggers per acquisition").locator("input").fill("20"); + await instrument.getByRole("button", { name: "Acquire" }).click(); + const group = "Oscilloscope - Pulse pair - acquisition 001"; + await expect(instrument.locator(".applications-status")).toHaveText( + `40 objects added to group '${group}'`, + { timeout: 60_000 }, + ); + await expect( + page.locator(".object-tree-group-name", { hasText: group }), + ).toBeVisible(); + + await instrument.getByRole("button", { name: "Close" }).click(); + await expect(instrument).toBeHidden(); + await expectMethodReady( + page, + "Pulse & Transient Characterization", + "org.datalab.pulse-characterization:two-channel-delay", + ); +}); + +test("Camera simulator acquires a photon transfer sequence", async ({ + warmPage: page, +}) => { + test.setTimeout(240_000); + await dismissAnyDialog(page); + await page.evaluate(() => window.runtime.resetAll()); + await page.getByRole("tab", { name: "Images" }).click(); + + await openPluginTool( + page, + "Camera & Detector Characterization", + "Scientific camera simulator", + ); + const instrument = page.getByRole("dialog", { + name: "Scientific camera simulator", + }); + await expect(instrument).toBeVisible(); + await expect(instrument.locator(".instrument-plot svg image")).toHaveCount( + 1, + { timeout: 60_000 }, + ); + await expect(instrument.locator(".instrument-summary")).toContainText( + "Flat frame, 10 ms", + ); + + const sequence = formRow(page, "Exposure times").locator("input"); + await expect(sequence).toBeDisabled(); + await formRow(page, "Mode").locator("select").selectOption("sequence"); + await expect(sequence).toBeEnabled({ timeout: 30_000 }); + await expect(instrument.locator(".instrument-summary")).toContainText( + "Flat frame, 136 ms", + { timeout: 30_000 }, + ); + + await instrument.getByRole("button", { name: "Acquire" }).click(); + const group = "Camera SN 20261007 - acquisition 001"; + await expect(instrument.locator(".applications-status")).toHaveText( + `28 objects added to group '${group}'`, + { timeout: 60_000 }, + ); + await expect( + page.locator(".object-tree-group-name", { hasText: group }), + ).toBeVisible(); + + await instrument.getByRole("button", { name: "Close" }).click(); + await expectMethodReady( + page, + "Camera & Detector Characterization", + "org.datalab.camera-characterization:photon-transfer", + ); +}); diff --git a/tests/python/test_applications.py b/tests/python/test_applications.py index 6a3e19e..747773c 100644 --- a/tests/python/test_applications.py +++ b/tests/python/test_applications.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import dataclasses import dlw_applications @@ -11,6 +12,12 @@ import numpy as np import pytest from datalab.plugin_examples import PluginExampleData +from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, +) +from datalab.plugin_tools import PluginTool from datalab.plugins import PluginBase, PluginCapability, PluginInfo from datalab.recipes import ( RECIPE_RUN_RECORD_OPTION, @@ -612,3 +619,170 @@ def materialize(_cls, example_id): assert opened["dirty"] is True assert opened["parameter_values"] == {RECIPE_ID: {"gain": 4.0}} assert [item["title"] for item in bootstrap.list_signals()] == ["Generated input"] + + +_SHUTTER = gds.ValueProp(True) + + +class GeneratorSettings(gds.DataSet): + """Settings of the test signal generator.""" + + amplitude = gds.FloatItem("Amplitude", default=1.0) + count = gds.IntItem("Shots", default=3, min=1) + shutter = gds.BoolItem("Shutter open", default=True).set_prop( + "display", store=_SHUTTER + ) + rate = gds.FloatItem("Rate", default=5.0).set_prop("display", active=_SHUTTER) + + +class SignalGenerator(PluginInstrument): + """Instrument generating ramps.""" + + live_interval_ms = 100 + + def __init__(self) -> None: + super().__init__(GeneratorSettings()) + + def preview(self) -> InstrumentFrame: + x = np.linspace(0.0, 1.0, 5) + signal = create_signal("Live", x, self.settings.amplitude * x) + return InstrumentFrame((signal,), "Live frame", (-1.0, 1.0)) + + def acquire(self) -> InstrumentAcquisition: + x = np.linspace(0.0, 1.0, 5) + return InstrumentAcquisition( + "Generator acquisition", + [create_signal(f"Shot {i}", x, x * i) for i in range(self.settings.count)], + ) + + +TOOLS = ( + PluginTool( + id="generator", + title="Signal generator...", + instrument="create_generator", + object_type="signal", + ), + PluginTool( + id="double", + title="Double signals...", + launcher="double_signals", + object_type="signal", + selection="at_least_one", + ), +) + + +@pytest.fixture +def tools_env(applications_env, monkeypatch): + """Give the managed application a launcher tool and an instrument tool.""" + bootstrap, record = applications_env + monkeypatch.setattr(GenericApplicationPlugin, "TOOLS", TOOLS, raising=False) + monkeypatch.setattr( + GenericApplicationPlugin, + "create_generator", + lambda self: SignalGenerator(), + raising=False, + ) + + async def double_signals(self): + for obj in self.get_selected_objects(): + bootstrap.add_signal_from_arrays(f"2 x {obj.title}", obj.x, 2 * obj.y) + + monkeypatch.setattr( + GenericApplicationPlugin, "double_signals", double_signals, raising=False + ) + record.instance.main = object() + dlw_applications.install_host( + bootstrap._MODEL, + bootstrap._object_uuid, + bootstrap.open_workspace_from_bytes, + "0.8.0", + signal_payload=bootstrap._signal_data_payload, + image_payload=bootstrap._image_data_payload, + ) + return bootstrap, record + + +def test_tools_are_listed_in_plugin_records(tools_env) -> None: + _bootstrap, record = tools_env + assert dlw_plugins._record_payload(record)["tools"] == [ + { + "id": "generator", + "title": "Signal generator...", + "description": "", + "kind": "instrument", + "object_type": "signal", + "selection": "none", + }, + { + "id": "double", + "title": "Double signals...", + "description": "", + "kind": "launcher", + "object_type": "signal", + "selection": "at_least_one", + }, + ] + + +def test_launcher_tool_runs_on_the_selection(tools_env) -> None: + bootstrap, record = tools_env + input_id = _add_input_signal(bootstrap) + + with pytest.raises(ValueError, match="Select at least one signal"): + asyncio.run(dlw_applications.launch_plugin_tool(PLUGIN_ID, "double")) + added = asyncio.run( + dlw_applications.launch_plugin_tool(PLUGIN_ID, "double", [input_id]) + ) + + assert added["image"] == [] + (new_id,) = added["signal"] + assert bootstrap._MODEL.get(new_id).title == "2 x Input" + assert record.instance.get_selected_objects() == [] + with pytest.raises(ValueError, match="is an instrument"): + asyncio.run(dlw_applications.launch_plugin_tool(PLUGIN_ID, "generator")) + + +def test_instrument_previews_acquires_and_resolves_active_settings( + tools_env, +) -> None: + bootstrap, record = tools_env + + opened = dlw_applications.open_plugin_instrument(PLUGIN_ID, "generator") + assert opened["title"] == "Signal generator" + assert opened["live_interval_ms"] == 100 + assert opened["settings"]["values"]["amplitude"] == 1.0 + + frame = dlw_applications.preview_plugin_instrument( + PLUGIN_ID, "generator", {"amplitude": 0.5} + ) + assert frame["kind"] == "signals" + assert frame["summary"] == "Live frame" + assert frame["value_range"] == [-1.0, 1.0] + (item,) = frame["items"] + assert item["encoding"] == "f64" + instrument = record.instance.get_instrument("generator") + assert instrument.settings.amplitude == 0.5 + + acquired = dlw_applications.acquire_plugin_instrument( + PLUGIN_ID, "generator", {"count": 2} + ) + assert acquired["panel"] == "signal" + assert acquired["group_title"] == "Generator acquisition" + group = next( + group + for group in bootstrap._MODEL.panel_tree("signal")["groups"] + if group["gid"] == acquired["group_id"] + ) + assert group["name"] == "Generator acquisition" + assert [obj["id"] for obj in group["objects"]] == acquired["object_ids"] + assert len(acquired["object_ids"]) == 2 + + active = dlw_applications.resolve_plugin_instrument_active( + PLUGIN_ID, "generator", {"shutter": False} + ) + assert active["rate"] is False + assert dlw_applications.resolve_plugin_instrument_active( + PLUGIN_ID, "generator", {"shutter": True} + )["rate"] diff --git a/tests/python/test_camera_application.py b/tests/python/test_camera_application.py index ba4b458..702044d 100644 --- a/tests/python/test_camera_application.py +++ b/tests/python/test_camera_application.py @@ -295,3 +295,52 @@ def test_camera_generated_examples_bind_and_run( outputs = {output["output_id"]: output for output in committed["objects"]} results = bootstrap.list_signal_results(outputs[anchor]["id"]) assert [result["title"] for result in results] == [table_title] + + +def test_camera_simulator_acquires_frames_ready_for_the_methods( + camera_application, +) -> None: + """The simulator tool shows live frames and acquires a usable campaign.""" + bootstrap = camera_application + dlw_applications.install_host( + bootstrap._MODEL, + bootstrap._object_uuid, + bootstrap.open_workspace_from_bytes, + "0.9.0", + bootstrap.reset_all, + signal_payload=bootstrap._signal_data_payload, + image_payload=bootstrap._image_data_payload, + ) + record = next( + item for item in dlw_plugins.list_plugins() if item["plugin_id"] == PLUGIN_ID + ) + assert record["tools"] == [ + { + "id": "camera-simulator", + "title": "Scientific camera simulator...", + "description": ( + "Acquire dark and flat frames from a simulated camera with live view" + ), + "kind": "instrument", + "object_type": "image", + "selection": "none", + } + ] + + opened = dlw_applications.open_plugin_instrument(PLUGIN_ID, "camera-simulator") + assert opened["title"] == "Scientific camera simulator" + frame = dlw_applications.preview_plugin_instrument(PLUGIN_ID, "camera-simulator") + assert frame["kind"] == "image" + assert frame["value_range"] == [0.0, 4095.0] + assert frame["items"][0]["width"] == 128 + + acquired = dlw_applications.acquire_plugin_instrument( + PLUGIN_ID, "camera-simulator", {"mode": "sequence"} + ) + assert acquired["panel"] == "image" + assert len(acquired["object_ids"]) == 4 + 2 * 12 + assessments = dlw_applications.assess_plugin_recipes( + PLUGIN_ID, acquired["object_ids"] + ) + assert assessments[RECIPE_ID]["status"] == "ready" + assert assessments[PTC_RECIPE_ID]["status"] == "ready" diff --git a/tests/python/test_pulse_application.py b/tests/python/test_pulse_application.py index 7b1ac61..fb490dc 100644 --- a/tests/python/test_pulse_application.py +++ b/tests/python/test_pulse_application.py @@ -334,3 +334,43 @@ def fail_on_second_output(*args, **kwargs): ) assert bootstrap._MODEL.panel_tree("signal") == before + + +def test_oscilloscope_simulator_acquires_pairs_for_the_delay_method( + pulse_application, +) -> None: + """The oscilloscope tool shows both channels and acquires paired shots.""" + bootstrap = pulse_application + dlw_applications.install_host( + bootstrap._MODEL, + bootstrap._object_uuid, + bootstrap.open_workspace_from_bytes, + "0.9.0", + bootstrap.reset_all, + signal_payload=bootstrap._signal_data_payload, + image_payload=bootstrap._image_data_payload, + ) + record = next( + item for item in dlw_plugins.list_plugins() if item["plugin_id"] == PLUGIN_ID + ) + (tool,) = record["tools"] + assert tool["id"] == "oscilloscope-simulator" + assert tool["kind"] == "instrument" + assert tool["object_type"] == "signal" + + values = {"source": "pulse-pair", "trigger_count": 20} + frame = dlw_applications.preview_plugin_instrument( + PLUGIN_ID, "oscilloscope-simulator", values + ) + assert frame["kind"] == "signals" + assert [item["title"] for item in frame["items"]] == ["CH1", "CH2"] + + acquired = dlw_applications.acquire_plugin_instrument( + PLUGIN_ID, "oscilloscope-simulator", values + ) + assert acquired["group_title"] == "Oscilloscope - Pulse pair - acquisition 001" + assert len(acquired["object_ids"]) == 40 + assessments = dlw_applications.assess_plugin_recipes( + PLUGIN_ID, acquired["object_ids"] + ) + assert assessments[f"{PLUGIN_ID}:two-channel-delay"]["status"] == "ready" diff --git a/tests/ts/actions/registry.test.ts b/tests/ts/actions/registry.test.ts index 07ea357..768fb6b 100644 --- a/tests/ts/actions/registry.test.ts +++ b/tests/ts/actions/registry.test.ts @@ -203,9 +203,10 @@ describe("buildPlotResultsAction", () => { }); describe("buildPluginActions", () => { - it("projects application recipes and examples under the plugin menu", () => { + it("projects application recipes, examples and tools under the plugin menu", () => { const onOpenApplicationRecipe = vi.fn(); const onOpenApplicationExample = vi.fn(); + const onLaunchApplicationTool = vi.fn(); const record: PluginRecord = { name: "application", record_id: "application", @@ -251,6 +252,32 @@ describe("buildPluginActions", () => { expected_checks: [], }, ], + tools: [ + { + id: "generator", + title: "Signal generator…", + description: "", + kind: "instrument", + object_type: "signal", + selection: "none", + }, + { + id: "inspect", + title: "Inspect signals…", + description: "", + kind: "launcher", + object_type: "signal", + selection: "at_least_one", + }, + { + id: "frames", + title: "Frame editor…", + description: "", + kind: "launcher", + object_type: "image", + selection: "none", + }, + ], operations: { can_enable: true, can_disable: true, @@ -263,6 +290,7 @@ describe("buildPluginActions", () => { onOpenApplications: vi.fn(), onOpenApplicationRecipe, onOpenApplicationExample, + onLaunchApplicationTool, onOpenManager: vi.fn(), onReloadAll: vi.fn(), }); @@ -296,6 +324,27 @@ describe("buildPluginActions", () => { "org.example.application", "quickstart", ); + + // Only the tools of the active panel are listed, after a separator + const tools = actions.filter((action) => + action.id.startsWith("plugin.application.tool."), + ); + expect(tools.map((action) => action.menuPath)).toEqual([ + "Plugins/Example & Application/Signal generator…", + "Plugins/Example & Application/Inspect signals…", + ]); + expect(tools.map((action) => action.beginGroup)).toEqual([true, false]); + const state = makeState(); + const [generator, inspect] = tools; + expect(generator.enabled(state)).toBe(true); + expect(inspect.enabled(state)).toBe(false); + expect(inspect.enabled({ ...state, selectedIds: ["s1"] })).toBe(true); + expect(inspect.enabled({ ...state, currentId: "s1" })).toBe(true); + inspect.run(); + expect(onLaunchApplicationTool).toHaveBeenCalledWith( + "org.example.application", + "inspect", + ); }); }); diff --git a/tests/ts/components/ApplicationsDialog.test.tsx b/tests/ts/components/ApplicationsDialog.test.tsx index 58eba0f..12ab89c 100644 --- a/tests/ts/components/ApplicationsDialog.test.tsx +++ b/tests/ts/components/ApplicationsDialog.test.tsx @@ -106,6 +106,7 @@ const APPLICATION: PluginRecord = { expected_checks: [], }, ], + tools: [], operations: { can_enable: true, can_disable: true, @@ -217,9 +218,11 @@ function renderDialog( return render( true} onCommitted={() => {}} onExampleOpened={() => {}} + onOpenTool={() => {}} onClose={() => {}} {...props} />, @@ -245,6 +248,53 @@ beforeEach(() => { }); describe("ApplicationsDialog", () => { + it("lists tools and opens those the selection allows", async () => { + const onOpenTool = vi.fn(); + runtimeMock.listPlugins.mockResolvedValue([ + { + ...APPLICATION, + recipes: [], + examples: [], + tools: [ + { + id: "simulator", + title: "Camera simulator…", + description: "Acquire frames from a simulated camera.", + kind: "instrument", + object_type: "image", + selection: "none", + }, + { + id: "inspect", + title: "Frame inspector…", + description: "", + kind: "launcher", + object_type: "image", + selection: "at_least_two", + }, + ], + }, + ]); + renderDialog({ candidateIds: ["image-1"], onOpenTool }); + + // A tools-only application is listed, its tools section open + const section = (await screen.findByText("Tools (2)")).closest("details")!; + expect(section.open).toBe(true); + const simulator = within( + section.querySelector('[data-tool-id="simulator"]')!, + ).getByRole("button", { name: "Open tool" }); + const inspect = within( + section.querySelector('[data-tool-id="inspect"]')!, + ).getByRole("button", { name: "Open tool" }); + expect( + screen.getByText("Acquire frames from a simulated camera."), + ).toBeTruthy(); + expect(inspect).toHaveProperty("disabled", true); + expect(inspect.getAttribute("title")).toBe("Select at least two images"); + fireEvent.click(simulator); + expect(onOpenTool).toHaveBeenCalledWith(PLUGIN_ID, "simulator"); + }); + it("reuses the launcher icon in the window header", async () => { render( { }); }); +describe("DataSetForm labels", () => { + it("shows only the checkbox text of an unlabeled BoolItem", () => { + const schema: JsonSchema = { + type: "object", + properties: { + shutter_open: { + type: "boolean", + "x-guidata-kind": "bool", + "x-guidata-text": "Shutter open", + }, + bare: { type: "boolean", "x-guidata-kind": "bool" }, + }, + "x-guidata-property-order": ["shutter_open", "bare"], + }; + const { container } = render( + {}} + />, + ); + const labels = [...container.querySelectorAll(".dataset-form-label")]; + expect(labels.map((label) => label.textContent)).toEqual(["", "bare"]); + expect(screen.getByLabelText("Shutter open")).toBeTruthy(); + }); +}); + describe("DataSetForm automatic sliders", () => { it("keeps exact numeric input beside an opt-in bounded slider", () => { const onChange = vi.fn(); diff --git a/tests/ts/components/InstrumentWindow.test.tsx b/tests/ts/components/InstrumentWindow.test.tsx new file mode 100644 index 0000000..77da728 --- /dev/null +++ b/tests/ts/components/InstrumentWindow.test.tsx @@ -0,0 +1,163 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { InstrumentWindow } from "../../../src/components/InstrumentWindow"; +import type { + PluginInstrumentFrame, + PluginInstrumentSession, +} from "../../../src/runtime/runtime"; +import type { RuntimeApi } from "../../../src/runtime/RuntimeApi"; + +const runtimeMock = vi.hoisted(() => ({ + openPluginInstrument: vi.fn(), + previewPluginInstrument: vi.fn(), + acquirePluginInstrument: vi.fn(), + resolvePluginInstrumentActive: vi.fn(), +})); + +vi.mock("../../../src/runtime/RuntimeContext", () => ({ + useRuntime: () => ({ runtime: runtimeMock as unknown as RuntimeApi }), +})); + +vi.mock("../../../src/components/InstrumentPlot", () => ({ + default: ({ frame }: { frame: PluginInstrumentFrame }) => ( +
+ {frame.kind}:{frame.items.length} +
+ ), +})); + +const SESSION: PluginInstrumentSession = { + plugin_id: "org.example.camera", + tool_id: "simulator", + title: "Camera simulator", + live_interval_ms: 20, + settings: { + schema: { + type: "object", + properties: { + exposure: { + type: "number", + "x-guidata-kind": "float", + "x-guidata-label": "Exposure time", + "x-guidata-name": "exposure", + }, + }, + "x-guidata-property-order": ["exposure"], + }, + values: { exposure: 10 }, + }, +}; + +const FRAME: PluginInstrumentFrame = { + kind: "signals", + items: [], + summary: "Mean 400 DN", + value_range: [0, 4095], +}; + +beforeEach(() => { + vi.clearAllMocks(); + runtimeMock.openPluginInstrument.mockResolvedValue(SESSION); + runtimeMock.previewPluginInstrument.mockResolvedValue(FRAME); + runtimeMock.resolvePluginInstrumentActive.mockResolvedValue({}); + runtimeMock.acquirePluginInstrument.mockResolvedValue({ + panel: "image", + group_id: "g1", + group_title: "Camera acquisition 001", + object_ids: ["image-1", "image-2"], + }); +}); + +function renderWindow(props: Partial[0]>) { + return render( + {}} + onClose={() => {}} + {...props} + />, + ); +} + +describe("InstrumentWindow", () => { + it("shows a live frame for the settings, then acquires into a group", async () => { + const onAcquired = vi.fn(); + renderWindow({ onAcquired }); + + expect( + await screen.findByRole("dialog", { name: "Camera simulator" }), + ).toBeTruthy(); + expect(await screen.findByTestId("instrument-plot")).toBeTruthy(); + expect(screen.getByText("Mean 400 DN")).toBeTruthy(); + expect(runtimeMock.previewPluginInstrument).toHaveBeenCalledWith( + "org.example.camera", + "simulator", + { exposure: 10 }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Acquire" })); + await waitFor(() => + expect(onAcquired).toHaveBeenCalledWith( + expect.objectContaining({ group_title: "Camera acquisition 001" }), + ), + ); + expect(runtimeMock.acquirePluginInstrument).toHaveBeenCalledWith( + "org.example.camera", + "simulator", + { exposure: 10 }, + ); + expect( + await screen.findByText( + "2 objects added to group 'Camera acquisition 001'", + ), + ).toBeTruthy(); + }); + + it("refreshes the view continuously in live mode", async () => { + renderWindow({}); + await screen.findByTestId("instrument-plot"); + const calls = runtimeMock.previewPluginInstrument.mock.calls.length; + + const live = screen.getByRole("button", { name: "Live" }); + fireEvent.click(live); + expect(live.getAttribute("aria-pressed")).toBe("true"); + await waitFor(() => + expect( + runtimeMock.previewPluginInstrument.mock.calls.length, + ).toBeGreaterThan(calls + 2), + ); + + fireEvent.click(live); + const stopped = runtimeMock.previewPluginInstrument.mock.calls.length; + await act(() => new Promise((resolve) => window.setTimeout(resolve, 80))); + expect(runtimeMock.previewPluginInstrument.mock.calls.length).toBe(stopped); + }); + + it("reports settings the instrument rejects", async () => { + runtimeMock.previewPluginInstrument.mockRejectedValue( + new Error("Exposure times must be positive"), + ); + runtimeMock.acquirePluginInstrument.mockRejectedValue( + new Error("This acquisition would create 400 megapixels"), + ); + const onAcquired = vi.fn(); + renderWindow({ onAcquired }); + + expect( + await screen.findByText("Exposure times must be positive"), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Acquire" })); + expect( + await screen.findByText("This acquisition would create 400 megapixels"), + ).toBeTruthy(); + expect(onAcquired).not.toHaveBeenCalled(); + }); +});