From cdcd83cd03f47c10e658c89a8f76d68406071098 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:47:09 +0200 Subject: [PATCH 1/5] feat(applications): method cards with live readiness and example trials --- CHANGELOG.md | 1 + doc/application-deep-links.md | 23 +- doc/architecture.md | 4 +- doc/plugins.md | 12 +- src/App.tsx | 57 +- src/actions/applicationDeepLinks.ts | 5 +- src/components/ApplicationMethodCard.tsx | 130 +++++ src/components/ApplicationsDialog.tsx | 331 ++++++++---- src/components/RecipeInputResolverDialog.tsx | 127 ++++- src/locales/fr.json | 36 +- ...a_characterization-0.2.0-py3-none-any.whl} | Bin 306866 -> 328574 bytes ...se_characterization-0.1.0-py3-none-any.whl | Bin 30774 -> 0 bytes ...se_characterization-0.2.0-py3-none-any.whl | Bin 0 -> 75123 bytes src/runtime/bundledPlugins.ts | 20 +- .../dlplugins/datalab/plugin_examples.py | 60 ++- src/runtime/dlplugins/datalab/plugins.py | 33 +- .../dlplugins/datalab/recipe_binding.py | 436 +++++++++++++++ src/runtime/dlplugins/datalab/recipes.py | 106 +++- src/runtime/dlw_applications.py | 343 ++++++++---- src/runtime/dlw_plugins.py | 33 +- src/runtime/runtime.ts | 97 +++- src/styles.css | 99 +++- src/utils/recipeInputs.ts | 107 ++++ tests/e2e/application_deeplinks.spec.ts | 6 +- tests/e2e/application_methods.spec.ts | 161 ++++++ tests/e2e/camera_application_ui.spec.ts | 12 +- tests/e2e/camera_bundle.spec.ts | 4 +- tests/e2e/pulse_application_ui.spec.ts | 14 +- tests/e2e/pulse_bundle.spec.ts | 6 +- tests/e2e/user_plugin_wheel.spec.ts | 2 +- tests/python/test_applications.py | 135 ++++- tests/python/test_camera_application.py | 94 +++- tests/python/test_pulse_application.py | 117 +++- tests/ts/actions/applicationDeepLinks.test.ts | 36 +- tests/ts/actions/registry.test.ts | 2 +- .../ts/components/ApplicationsDialog.test.tsx | 509 ++++++++++++------ .../RecipeInputResolverDialog.test.tsx | 161 +++++- tests/ts/runtime/bundledPlugins.test.ts | 2 +- 38 files changed, 2788 insertions(+), 533 deletions(-) create mode 100644 src/components/ApplicationMethodCard.tsx rename src/runtime/builtin_wheels/{datalab_camera_characterization-0.1.0-py3-none-any.whl => datalab_camera_characterization-0.2.0-py3-none-any.whl} (85%) delete mode 100644 src/runtime/builtin_wheels/datalab_pulse_characterization-0.1.0-py3-none-any.whl create mode 100644 src/runtime/builtin_wheels/datalab_pulse_characterization-0.2.0-py3-none-any.whl create mode 100644 src/runtime/dlplugins/datalab/recipe_binding.py create mode 100644 src/utils/recipeInputs.ts create mode 100644 tests/e2e/application_methods.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c04c5..aee43b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ### Changed +- **Applications:** each method now shows the inputs it expects and a live status telling whether the current selection can be analyzed, and why not. _Run on selection…_ runs a ready method directly and asks for the input assignment only when it is ambiguous or invalid; the assignment dialog describes each input, flags objects lacking required metadata, and checks the chosen objects before _Continue_. Examples are listed under the methods they are designed for: _Try with this example_ opens the example and runs the method with prefilled parameters. One example may serve several methods, and examples designed for no method are listed as datasets. - **Histogram range editors:** generic parameter intervals now respect linked numeric constraints and read-only states. Brightness and contrast retains its specialized controls, including exact float64 window editing outside the source image's observed range. - **Guidata compatibility:** browser runtimes now require guidata 3.15 and use its native async DataSet backend and JSON Schema exporter. The former diff --git a/doc/application-deep-links.md b/doc/application-deep-links.md index aba7960..ac37830 100644 --- a/doc/application-deep-links.md +++ b/doc/application-deep-links.md @@ -16,7 +16,7 @@ All five parameters are required. For example, the Camera quickstart link is: ```text ?plugin=org.datalab.camera-characterization -&pluginVersion=0.1.0 +&pluginVersion=0.2.0 &recipe=org.datalab.camera-characterization:relative-dn-characterization &recipeVersion=1.1.0 &example=quickstart @@ -26,12 +26,23 @@ The Pulse demo uses: ```text ?plugin=org.datalab.pulse-characterization -&pluginVersion=0.1.0 +&pluginVersion=0.2.0 &recipe=org.datalab.pulse-characterization:single-channel-campaign &recipeVersion=1.1.0 &example=demo ``` +The other bundled methods use recipe version `1.0.0` with these recipe and example pairs: + +| Plugin | Recipe (local ID) | Example | +| ------ | ----------------------- | -------------------- | +| Camera | `photon-transfer` | `photon-transfer` | +| Camera | `dark-current` | `dark-ramp` | +| Pulse | `shot-stability` | `stability-demo` | +| Pulse | `step-response` | `step-response-demo` | +| Pulse | `two-channel-delay` | `two-channel-demo` | +| Pulse | `pulse-height-spectrum` | `spectrum-demo` | + These blocks are wrapped for readability. Remove the line breaks when building a URL, or construct the query with `URLSearchParams` so identifiers are encoded correctly. @@ -41,8 +52,8 @@ correctly. Once Pyodide is ready, DataLab-Web resolves the request from the live managed plugin registry. The matching record must be loaded and enabled, have source `bundled-wheel`, and have trust status `verified`. Plugin ID, plugin version, -recipe ID, recipe version, example ID, and the example-to-recipe link must all -match exactly. +recipe ID, recipe version, and example ID must all match exactly, and the +example must list the recipe among the methods it is designed for. Validation finishes before the workspace is changed. An unknown plugin, incomplete request, version mismatch, unsupported example, disabled plugin, or @@ -56,9 +67,7 @@ After validation: Images panel, and marks the loaded workspace clean. - Pulse replaces the workspace with its deterministic 500-shot campaign, opens the Signals panel, and marks the generated workspace as unsaved. -- The Applications dialog selects the requested plugin, highlights the recipe, - and carries any generated example parameter defaults into its parameter - dialog. The recipe is never executed until the user clicks _Start analysis_. +- The Applications dialog selects the requested plugin and highlights the method. Its readiness status covers every object selected by the example, even when only part of them is visibly selected, and the example's parameter values for this method prefill its parameter dialog. The method never runs until the user clicks _Run on selection…_. The generic same-origin `preload` workspace parameter remains available for ordinary HDF5 demos. When Applications parameters are present, their validated diff --git a/doc/architecture.md b/doc/architecture.md index 51720a5..4db4d7a 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -397,7 +397,9 @@ param.edit_async(...)` for parameter dialogs. hot-reload, cleanup, capabilities, recipes, examples, and menu wiring. - **`dlw_applications.py`** — generic application host. It prepares typed - recipe slots, bridges guidata parameter schemas, opens packaged/generated + recipe slots, assesses recipe readiness on candidate objects and edited + bindings (through the shim's `datalab.recipe_binding`, shared with Desktop), + bridges guidata parameter schemas, opens packaged/generated examples, and commits recipe objects, results, diagnostics, and provenance transactionally. It contains no Camera- or Pulse-specific paths. diff --git a/doc/plugins.md b/doc/plugins.md index 5db830b..4137d56 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -126,12 +126,12 @@ my-plugin = "my_package.adapters.desktop:MyDesktopPlugin" The Web target must be a subclass of the portable `PluginBase` and provide a stable namespaced `PluginInfo.id`. Application plugins include `PluginCapability.APPLICATION`, declare `RECIPES` and optionally `EXAMPLES`, -and may implement `suggest_recipe_bindings()` or `materialize_example()`. -Recipe slots carry signal/image type, `ONE`/`MANY` cardinality, and required -state. Parameters are guidata `DataSet` classes. Outputs are returned as a +and may implement `materialize_example()`. Recipe slots carry signal/image type, `ONE`/`MANY` cardinality, required state, a title and description, a minimum count, and metadata requirements; recipes may add `suggest_bindings` and `check_inputs` hooks. An example lists the recipes it is designed for in `recipe_ids`, and generated examples key their parameter values by recipe ID. Parameters are guidata `DataSet` classes. Outputs are returned as a `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. + Version 1 of the installer deliberately accepts only local `*-none-any` pure-Python wheels compatible with Pyodide's Python version. Native payloads, archive traversal, reserved host namespaces, and distribution/package/plugin @@ -167,7 +167,7 @@ through the real worker-hosted Pyodide runtime. It requires a visible Plotly response trace, decoded non-blank PRNU-map pixels, and the anchored metrics table in the Results panel. It also limits incremental WASM-heap growth to 64 MiB and retained output arrays to three times the input arrays. The status -qualifies DataLab-Web 0.8.0, Pyodide 0.26.4, Camera 0.1.0, and relative-DN +qualifies DataLab-Web 0.8.0, Pyodide 0.26.4, Camera 0.2.0, and relative-DN recipe 1.1.0. The Pulse gate in `tests/e2e/pulse_bundle.spec.ts` executes the deterministic @@ -176,7 +176,9 @@ outputs plus the anchored 500-row metrics table. It requires visible Plotly traces for amplitude, raw mean, and aligned mean; all six quality statuses; 489 valid/aligned shots; no more than 64 MiB incremental WASM heap; and exactly 24,032 bytes of retained output arrays. This qualifies DataLab-Web 0.8.0, -Pyodide 0.26.4, Pulse 0.1.0, and pulse campaign recipe 1.1.0. +Pyodide 0.26.4, Pulse 0.2.0, and pulse campaign recipe 1.1.0. + +Since version 0.2.0, Camera also offers photon transfer and dark-current recipes, and Pulse offers shot-to-shot stability, step-response, two-channel delay and pulse-height spectrum recipes, each with a generated example. `tests/e2e/application_methods.spec.ts` opens every new example through its deep link, checks that its method is ready, runs it from the Applications dialog and checks the created outputs in the visible object tree; it also runs the relative-DN method through _Try with this example_ on the photon transfer ladder. `tests/python/test_camera_application.py` and `tests/python/test_pulse_application.py` run the same pairs, plus the photon transfer → relative-DN and laser warm-up → campaign pairs, through the generic host and check that every example binds its recipe slots without ambiguity. ## Hot reload diff --git a/src/App.tsx b/src/App.tsx index 0c06d7d..1231c49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,7 +113,10 @@ import { useProgress } from "./components/ProgressDialog"; import { useToast } from "./components/Toast"; import type { SeparateViewContent } from "./components/SeparateViewDialog"; import { PluginManagerDialog } from "./components/PluginManagerDialog"; -import { ApplicationsDialog } from "./components/ApplicationsDialog"; +import { + ApplicationsDialog, + type PluginExampleContext, +} from "./components/ApplicationsDialog"; import { ObjectPropertiesDialog } from "./components/ObjectPropertiesDialog"; import { RoiPanel } from "./components/RoiPanel"; import type { RoiDrawGeometry } from "./components/RoiPanel"; @@ -839,9 +842,12 @@ export default function App() { const [applicationTarget, setApplicationTarget] = useState<{ pluginId: string; recipeId: string; - parameterValues: Record; - candidateIds?: string[]; } | null>(null); + // Last example opened from an application plugin. Its full selection + // stays the recipe candidate set while the visible selection is the + // (possibly reduced) one set on opening it; its values prefill recipes. + const [pluginExampleContext, setPluginExampleContext] = + useState(null); const [annotations, setAnnotations] = useState({ shapes: [], annotations: [], @@ -1687,6 +1693,18 @@ export default function App() { }); }, [confirm, workspaceDirty]); + // Recipe candidates: the live selection, or the full selection of the + // last opened example while the visible selection is still the one set + // on opening it (large signal sets are only partly displayed). + const applicationCandidateIds = useMemo(() => { + const live = + selectedIds.length > 0 ? selectedIds : currentId ? [currentId] : []; + const context = pluginExampleContext; + if (!context || context.visibleIds.length !== live.length) return live; + const visible = new Set(context.visibleIds); + return live.every((id) => visible.has(id)) ? context.objectIds : live; + }, [currentId, pluginExampleContext, selectedIds]); + const handlePluginRecipeCommitted = useCallback( async (commit: PluginRecipeCommit) => { const primaryOutput = @@ -1725,7 +1743,14 @@ export default function App() { : result.selected_ids; setSelectedIds(visualSelection); setCurrentId(result.current_id); + setPluginExampleContext({ + pluginId: result.plugin_id, + objectIds: result.selected_ids, + visibleIds: visualSelection, + parameterValues: result.parameter_values, + }); } else { + setPluginExampleContext(null); await refresh(null); } }, @@ -1741,7 +1766,7 @@ export default function App() { const handleOpenApplicationRecipe = useCallback( (pluginId: string, recipeId: string) => { - setApplicationTarget({ pluginId, recipeId, parameterValues: {} }); + setApplicationTarget({ pluginId, recipeId }); setApplicationsOpen(true); }, [], @@ -1758,16 +1783,8 @@ export default function App() { true, ); await handlePluginExampleOpened(result); - const example = pluginRecords - .find((record) => record.plugin_id === pluginId) - ?.examples.find((candidate) => candidate.id === exampleId); - if (example?.recipe_id) { - setApplicationTarget({ - pluginId, - recipeId: example.recipe_id, - parameterValues: result.parameter_values, - candidateIds: result.selected_ids, - }); + if (result.recipe_id) { + setApplicationTarget({ pluginId, recipeId: result.recipe_id }); setApplicationsOpen(true); } } catch (error) { @@ -1782,7 +1799,6 @@ export default function App() { [ confirmOpenPluginExample, handlePluginExampleOpened, - pluginRecords, runtime, showProcessingError, ], @@ -3654,8 +3670,6 @@ export default function App() { setApplicationTarget({ pluginId: result.pluginId, recipeId: result.recipeId, - parameterValues: result.example.parameter_values, - candidateIds: result.example.selected_ids, }); setApplicationsOpen(true); pushToast({ @@ -5371,13 +5385,8 @@ export default function App() { )} {applicationsOpen && ( 0 - ? selectedIds - : currentId - ? [currentId] - : [] - } + candidateIds={applicationCandidateIds} + exampleContext={pluginExampleContext} initialTarget={applicationTarget} confirmOpenExample={confirmOpenPluginExample} onCommitted={handlePluginRecipeCommitted} diff --git a/src/actions/applicationDeepLinks.ts b/src/actions/applicationDeepLinks.ts index 2b1bbb4..d2f9b7d 100644 --- a/src/actions/applicationDeepLinks.ts +++ b/src/actions/applicationDeepLinks.ts @@ -68,14 +68,15 @@ export async function openBundledApplicationDeepLink( if (!example) { return mismatch("example", request.exampleId, record.examples[0]?.id ?? ""); } - if (example.recipe_id !== recipe.id) { - return mismatch("recipe", request.recipeId, example.recipe_id ?? ""); + if (!example.recipe_ids.includes(recipe.id)) { + return mismatch("recipe", request.recipeId, example.recipe_ids[0] ?? ""); } const opened = await runtime.openPluginExample( request.pluginId, request.exampleId, true, + recipe.id, ); return { kind: "opened", diff --git a/src/components/ApplicationMethodCard.tsx b/src/components/ApplicationMethodCard.tsx new file mode 100644 index 0000000..8735e2c --- /dev/null +++ b/src/components/ApplicationMethodCard.tsx @@ -0,0 +1,130 @@ +import { t } from "../i18n/translate"; +import type { + PluginExampleSummary, + PluginRecipeAssessment, + PluginRecipeSummary, +} from "../runtime/runtime"; +import { formatReadiness, formatSlotKind } from "../utils/recipeInputs"; + +interface Props { + recipe: PluginRecipeSummary; + /** Examples designed for this recipe. */ + examples: PluginExampleSummary[]; + /** Readiness on the current selection; undefined while it is checked. */ + assessment: PluginRecipeAssessment | undefined; + focused: boolean; + disabled: boolean; + onRun: () => void; + onTryExample: (exampleId: string) => void; +} + +/** One method of an application: expected inputs, readiness and examples. */ +export function ApplicationMethodCard({ + recipe, + examples, + assessment, + focused, + disabled, + onRun, + onTryExample, +}: Props) { + const readiness = assessment + ? formatReadiness(assessment, recipe.inputs) + : null; + return ( +
+
+
+ {recipe.title} + {recipe.description &&

{recipe.description}

} +
+ +
+ {recipe.inputs.length > 0 && ( +
+ + {t("Expected inputs")} + +
    + {recipe.inputs.map((slot) => ( +
  • + {slot.title}{" "} + + {formatSlotKind(slot)} + + {slot.description &&

    {slot.description}

    } + {slot.metadata.length > 0 && ( +
      + {slot.metadata.map((requirement) => ( +
    • + {requirement.key}{" "} + + ( + {requirement.required + ? t("required metadata") + : t("hint")} + ) + + {requirement.description && + ` — ${requirement.description}`} +
    • + ))} +
    + )} +
  • + ))} +
+
+ )} +
+ + {readiness?.summary ?? t("Checking the current selection…")} + + {readiness && readiness.reasons.length > 0 && ( +
    + {readiness.reasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+ )} +
+ {examples.length > 0 && ( +
+ + {t("Examples designed for this method")} + + {examples.map((example) => ( +
+
+ {example.title} + {example.description &&

{example.description}

} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/ApplicationsDialog.tsx b/src/components/ApplicationsDialog.tsx index bb73729..50ad8c6 100644 --- a/src/components/ApplicationsDialog.tsx +++ b/src/components/ApplicationsDialog.tsx @@ -4,21 +4,30 @@ import { t } from "../i18n/translate"; import { useRuntime } from "../runtime/RuntimeContext"; import type { PluginExampleOpenResult, + PluginRecipeAssessment, PluginRecord, PluginRecipeCommit, PluginRecipePreparation, } from "../runtime/runtime"; +import { ApplicationMethodCard } from "./ApplicationMethodCard"; import { DataSetDialog } from "./DataSetDialog"; import { RecipeInputResolverDialog } from "./RecipeInputResolverDialog"; +/** Last example opened from an application plugin. */ +export interface PluginExampleContext { + pluginId: string; + /** Every object selected by the example. */ + objectIds: string[]; + /** Objects visibly selected after opening it (possibly fewer). */ + visibleIds: string[]; + /** Parameter values suited to the example, per recipe ID. */ + parameterValues: Record>; +} + interface Props { candidateIds: string[]; - initialTarget?: { - pluginId: string; - recipeId: string; - parameterValues: Record; - candidateIds?: string[]; - } | null; + exampleContext?: PluginExampleContext | null; + initialTarget?: { pluginId: string; recipeId: string } | null; confirmOpenExample: () => boolean | Promise; onCommitted: (commit: PluginRecipeCommit) => void | Promise; onExampleOpened: (result: PluginExampleOpenResult) => void | Promise; @@ -30,6 +39,14 @@ interface PendingParameters { bindings: Record; } +interface PendingInputs { + preparation: PluginRecipePreparation; + values: Record; +} + +/** Delay before assessing the recipes on a new selection. */ +const READINESS_DELAY_MS = 250; + function isApplication(record: PluginRecord): boolean { return Boolean( record.enabled && @@ -40,8 +57,46 @@ function isApplication(record: PluginRecord): boolean { ); } +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +/** Return the example values of a recipe when all candidates come from it. */ +function exampleValuesFor( + context: PluginExampleContext | null, + pluginId: string, + recipeId: string, + candidateIds: string[], +): Record { + if (!context || context.pluginId !== pluginId || candidateIds.length === 0) { + return {}; + } + const exampleIds = new Set(context.objectIds); + return candidateIds.every((id) => exampleIds.has(id)) + ? (context.parameterValues[recipeId] ?? {}) + : {}; +} + +/** Prefill the parameter form of a preparation with example values. */ +function withValues( + preparation: PluginRecipePreparation, + values: Record, +): PluginRecipePreparation { + if (!preparation.parameters || Object.keys(values).length === 0) { + return preparation; + } + return { + ...preparation, + parameters: { + ...preparation.parameters, + values: { ...preparation.parameters.values, ...values }, + }, + }; +} + export function ApplicationsDialog({ candidateIds, + exampleContext = null, initialTarget = null, confirmOpenExample, onCommitted, @@ -59,29 +114,17 @@ export function ApplicationsDialog({ const [diagnostics, setDiagnostics] = useState< PluginRecipeCommit["diagnostics"] >([]); - const [pendingInputs, setPendingInputs] = - useState(null); + const [assessments, setAssessments] = useState< + Record + >({}); + const [pendingInputs, setPendingInputs] = useState( + null, + ); const [pendingParameters, setPendingParameters] = useState(null); - const [exampleParameters, setExampleParameters] = useState< - Record> - >( - initialTarget - ? { [initialTarget.recipeId]: initialTarget.parameterValues } - : {}, - ); - const [exampleCandidateIds, setExampleCandidateIds] = useState< - string[] | null - >(initialTarget?.candidateIds ?? null); useEffect(() => { - if (!initialTarget) return; - setSelectedId(initialTarget.pluginId); - setExampleParameters((current) => ({ - ...current, - [initialTarget.recipeId]: initialTarget.parameterValues, - })); - setExampleCandidateIds(initialTarget.candidateIds ?? null); + if (initialTarget) setSelectedId(initialTarget.pluginId); }, [initialTarget]); useEffect(() => { @@ -100,9 +143,7 @@ export function ApplicationsDialog({ ); }) .catch((reason) => { - if (!cancelled) { - setError(reason instanceof Error ? reason.message : String(reason)); - } + if (!cancelled) setError(errorMessage(reason)); }); return () => { cancelled = true; @@ -129,6 +170,52 @@ export function ApplicationsDialog({ [records, selectedId], ); + // Live readiness of every method on the current selection. Keys keep the + // effect from re-running when App re-creates equal arrays. + const selectedPluginId = selected?.plugin_id ?? null; + const recipeKey = selected?.recipes.map((recipe) => recipe.id).join("\n"); + const candidateKey = candidateIds.join("\n"); + useEffect(() => { + if (!runtime || !selectedPluginId || !recipeKey || busy) return; + let cancelled = false; + const recipeIds = recipeKey.split("\n"); + const ids = candidateKey ? candidateKey.split("\n") : []; + const timer = window.setTimeout(() => { + const values = Object.fromEntries( + recipeIds.map((recipeId) => [ + recipeId, + exampleValuesFor(exampleContext, selectedPluginId, recipeId, ids), + ]), + ); + runtime + .assessPluginRecipes(selectedPluginId, ids, values) + .then((result) => { + if (!cancelled) setAssessments(result); + }) + .catch((reason) => { + if (cancelled) return; + const failure = { + status: "error" as const, + error: errorMessage(reason), + }; + setAssessments( + Object.fromEntries(recipeIds.map((id) => [id, failure])), + ); + }); + }, READINESS_DELAY_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [ + busy, + candidateKey, + exampleContext, + recipeKey, + runtime, + selectedPluginId, + ]); + const execute = useCallback( async ( preparation: PluginRecipePreparation, @@ -153,9 +240,7 @@ export function ApplicationsDialog({ ); setPendingParameters(null); } catch (reason) { - const message = - reason instanceof Error ? reason.message : String(reason); - setError(message); + setError(errorMessage(reason)); throw reason; } finally { setBusy(false); @@ -179,67 +264,111 @@ export function ApplicationsDialog({ [execute], ); - const startRecipe = async (pluginId: string, recipeId: string) => { + /** Assess the bindings edited in the input resolver. */ + const checkPendingBindings = useCallback( + async (bindings: Record) => { + if (!runtime || !pendingInputs) { + throw new Error("No recipe awaits its inputs"); + } + return runtime.checkPluginRecipeBindings( + pendingInputs.preparation.plugin_id, + pendingInputs.preparation.recipe_id, + bindings, + pendingInputs.values, + ); + }, + [pendingInputs, runtime], + ); + + /** Prepare a recipe; ask for the inputs unless they are ready to run. */ + const startRecipe = async ( + pluginId: string, + recipeId: string, + ids: string[], + values: Record, + ) => { if (!runtime) return; setBusy(true); setError(null); setStatus(null); + setDiagnostics([]); try { const prepared = await runtime.preparePluginRecipe( pluginId, recipeId, - exampleCandidateIds ?? candidateIds, + ids, + values, ); - const defaults = exampleParameters[recipeId]; - const preparation = - defaults && prepared.parameters - ? { - ...prepared, - parameters: { - ...prepared.parameters, - values: { ...prepared.parameters.values, ...defaults }, - }, - } - : prepared; - if ( - preparation.ambiguous_slots.length > 0 || - preparation.missing_slots.length > 0 - ) { - setPendingInputs(preparation); + const preparation = withValues(prepared, values); + if (prepared.readiness.status === "ready") { + await continuePreparation(preparation, prepared.readiness.bindings); } else { - await continuePreparation(preparation, preparation.bindings); + setPendingInputs({ preparation, values }); } } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); + setError(errorMessage(reason)); } finally { setBusy(false); } }; - const openExample = async (pluginId: string, exampleId: string) => { - if (!runtime) return; - if (!(await confirmOpenExample())) return; + const runOnSelection = (pluginId: string, recipeId: string) => + startRecipe( + pluginId, + recipeId, + candidateIds, + exampleValuesFor(exampleContext, pluginId, recipeId, candidateIds), + ); + + const openExample = async ( + pluginId: string, + exampleId: string, + recipeId: string | null, + ): Promise => { + if (!runtime) return null; + if (!(await confirmOpenExample())) return null; setBusy(true); setError(null); setStatus(null); + setDiagnostics([]); try { - const result = await runtime.openPluginExample(pluginId, exampleId, true); - setExampleCandidateIds(result.selected_ids); + const result = await runtime.openPluginExample( + pluginId, + exampleId, + true, + recipeId, + ); await onExampleOpened(result); - if (result.recipe_id) { - setExampleParameters((current) => ({ - ...current, - [result.recipe_id!]: result.parameter_values, - })); - } setStatus(t("Example opened")); + return result; } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); + setError(errorMessage(reason)); + return null; } finally { setBusy(false); } }; + /** Open an example, then start the method on the objects it selects. */ + const tryExample = async ( + pluginId: string, + exampleId: string, + recipeId: string, + ) => { + const result = await openExample(pluginId, exampleId, recipeId); + if (!result) return; + await startRecipe( + pluginId, + recipeId, + result.selected_ids, + result.parameter_values[recipeId] ?? {}, + ); + }; + + const datasets = + selected?.examples.filter((example) => example.recipe_ids.length === 0) ?? + []; + return ( <>
{ - setSelectedId(record.plugin_id); - setExampleCandidateIds(null); - }} + onClick={() => setSelectedId(record.plugin_id)} > {record.info?.name ?? record.plugin_id} {record.info?.version ?? record.version} @@ -310,39 +436,41 @@ export function ApplicationsDialog({ {selected.recipes.length > 0 && (
-

{t("Recipes")}

+

{t("Methods")}

{selected.recipes.map((recipe) => ( -
-
- {recipe.title} - {recipe.description &&

{recipe.description}

} -
- -
+ recipe={recipe} + examples={selected.examples.filter((example) => + example.recipe_ids.includes(recipe.id), + )} + assessment={assessments[recipe.id]} + focused={recipe.id === initialTarget?.recipeId} + disabled={busy} + onRun={() => + void runOnSelection(selected.plugin_id!, recipe.id) + } + onTryExample={(exampleId) => + void tryExample( + selected.plugin_id!, + exampleId, + recipe.id, + ) + } + /> ))}
)} - {selected.examples.length > 0 && ( + {datasets.length > 0 && (
-

{t("Examples")}

- {selected.examples.map((example) => ( -
+

{t("Datasets")}

+ {datasets.map((example) => ( +
{example.title} {example.description && ( @@ -351,11 +479,15 @@ export function ApplicationsDialog({
))} @@ -393,10 +525,13 @@ export function ApplicationsDialog({ )}
- {pendingInputs && ( + {pendingInputs && runtime && ( continuePreparation(pendingInputs, bindings)} + preparation={pendingInputs.preparation} + onCheck={checkPendingBindings} + onSubmit={(bindings) => + continuePreparation(pendingInputs.preparation, bindings) + } onCancel={() => setPendingInputs(null)} /> )} diff --git a/src/components/RecipeInputResolverDialog.tsx b/src/components/RecipeInputResolverDialog.tsx index bbbf5f2..0421985 100644 --- a/src/components/RecipeInputResolverDialog.tsx +++ b/src/components/RecipeInputResolverDialog.tsx @@ -1,15 +1,38 @@ -import { useMemo, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { t } from "../i18n/translate"; -import type { PluginRecipePreparation } from "../runtime/runtime"; +import type { + PluginRecipeCandidate, + PluginRecipePreparation, + PluginRecipeReadiness, +} from "../runtime/runtime"; +import { formatReadiness, formatSlotKind } from "../utils/recipeInputs"; interface Props { preparation: PluginRecipePreparation; + /** Assess edited bindings (slot declarations and recipe input checks). */ + onCheck: ( + bindings: Record, + ) => Promise; onSubmit: (bindings: Record) => void | Promise; onCancel: () => void; } +/** Delay before assessing bindings edited by the user. */ +const CHECK_DELAY_MS = 200; + +function candidateLabel( + candidate: PluginRecipeCandidate, + slotId: string, +): string { + const missing = candidate.missing_metadata[slotId] ?? []; + return missing.length > 0 + ? `${candidate.title} ${t("(missing: {keys})", { keys: missing.join(", ") })}` + : candidate.title; +} + export function RecipeInputResolverDialog({ preparation, + onCheck, onSubmit, onCancel, }: Props) { @@ -17,22 +40,48 @@ export function RecipeInputResolverDialog({ Object.fromEntries( preparation.slots.map((slot) => [ slot.id, - [...(preparation.bindings[slot.id] ?? [])], + [...(preparation.readiness.bindings[slot.id] ?? [])], ]), ), ); + const initialBindings = useRef(bindings); + const [readiness, setReadiness] = useState( + preparation.readiness, + ); + const [checking, setChecking] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const valid = useMemo( - () => - preparation.slots.every((slot) => { - const count = bindings[slot.id]?.length ?? 0; - if (slot.required && count === 0) return false; - return slot.cardinality === "many" || count <= 1; - }), - [bindings, preparation.slots], - ); + useEffect(() => { + if (bindings === initialBindings.current) return; + let cancelled = false; + setChecking(true); + const timer = window.setTimeout(() => { + onCheck(bindings) + .then((result) => { + if (cancelled) return; + setReadiness(result); + setError(null); + }) + .catch((reason) => { + if (!cancelled) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + }, CHECK_DELAY_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [bindings, onCheck]); + + const valid = + !checking && + (readiness.status === "ready" || readiness.status === "warnings"); + const { summary, reasons } = formatReadiness(readiness, preparation.slots); const submit = async () => { if (!valid) return; @@ -55,7 +104,7 @@ export function RecipeInputResolverDialog({ aria-label={t("Recipe inputs")} >
-

{t("Recipe inputs")}

+

{t("Inputs of '{title}'", { title: preparation.title })}

{preparation.slots.map((slot) => { const candidates = preparation.candidates.filter((candidate) => @@ -65,15 +114,33 @@ export function RecipeInputResolverDialog({ return (
- {slot.id.replaceAll("_", " ")} - - {slot.object_type === "signal" ? t("Signal") : t("Image")} - {slot.required ? ` · ${t("Required")}` : ""} - + {slot.title} + {formatSlotKind(slot)} + {slot.description && ( +

{slot.description}

+ )} + {slot.metadata.length > 0 && ( +
    + {slot.metadata.map((requirement) => ( +
  • + {requirement.key}{" "} + + ( + {requirement.required + ? t("required metadata") + : t("hint")} + ) + + {requirement.description && + ` — ${requirement.description}`} +
  • + ))} +
+ )} {slot.cardinality === "one" ? ( @@ -110,7 +177,7 @@ export function RecipeInputResolverDialog({ }) } /> - {candidate.title} + {candidateLabel(candidate, slot.id)} ))}
@@ -121,6 +188,24 @@ export function RecipeInputResolverDialog({ ); })}
+
+ + {checking ? t("Checking the current selection…") : summary} + + {!checking && reasons.length > 0 && ( +
    + {reasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+ )} +
{error &&
{error}
}
- - {recipe.inputs.length > 0 && ( -
- - {t("Expected inputs")} - -
    - {recipe.inputs.map((slot) => ( -
  • - {slot.title}{" "} - - {formatSlotKind(slot)} - - {slot.description &&

    {slot.description}

    } - {slot.metadata.length > 0 && ( -
      - {slot.metadata.map((requirement) => ( -
    • - {requirement.key}{" "} - - ( - {requirement.required - ? t("required metadata") - : t("hint")} - ) - - {requirement.description && - ` — ${requirement.description}`} -
    • - ))} -
    - )} -
  • - ))} -
+ + + {recipe.title} + v{recipe.version} + +
+
+
{recipe.description &&

{recipe.description}

}
+ +
+ {recipe.inputs.length > 0 && ( +
+ + {t("Expected inputs")} + +
    + {recipe.inputs.map((slot) => ( +
  • + {slot.title}{" "} + + {formatSlotKind(slot)} + + {slot.description &&

    {slot.description}

    } + {slot.metadata.length > 0 && ( +
      + {slot.metadata.map((requirement) => ( +
    • + {requirement.key}{" "} + + ( + {requirement.required + ? t("required metadata") + : t("hint")} + ) + + {requirement.description && + ` — ${requirement.description}`} +
    • + ))} +
    + )} +
  • + ))} +
+
+ )} +
+ {summary} + {readiness && readiness.reasons.length > 0 && ( +
    + {readiness.reasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+ )}
- )} -
- - {readiness?.summary ?? t("Checking the current selection…")} - - {readiness && readiness.reasons.length > 0 && ( -
    - {readiness.reasons.map((reason, index) => ( -
  • {reason}
  • + {examples.length > 0 && ( +
    + + {t("Examples designed for this method")} + + {examples.map((example) => ( +
    +
    + {example.title} + {example.description &&

    {example.description}

    } +
    + +
    ))} -
+
)}
- {examples.length > 0 && ( -
- - {t("Examples designed for this method")} - - {examples.map((example) => ( -
-
- {example.title} - {example.description &&

{example.description}

} -
- -
- ))} -
- )} - + ); } diff --git a/src/components/ApplicationsDialog.tsx b/src/components/ApplicationsDialog.tsx index 50ad8c6..7021b0e 100644 --- a/src/components/ApplicationsDialog.tsx +++ b/src/components/ApplicationsDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { getRootIconUrl } from "../assets/rootIcons"; import { t } from "../i18n/translate"; import { useRuntime } from "../runtime/RuntimeContext"; @@ -9,6 +9,7 @@ import type { PluginRecipeCommit, PluginRecipePreparation, } from "../runtime/runtime"; +import { usePersistedBool } from "../utils/persisted"; import { ApplicationMethodCard } from "./ApplicationMethodCard"; import { DataSetDialog } from "./DataSetDialog"; import { RecipeInputResolverDialog } from "./RecipeInputResolverDialog"; @@ -47,6 +48,13 @@ interface PendingInputs { /** Delay before assessing the recipes on a new selection. */ const READINESS_DELAY_MS = 250; +/** Storage key remembering whether the application list is hidden. */ +export const APPLICATIONS_LIST_COLLAPSED_KEY = + "datalab-web.applications.listCollapsed"; + +/** Accordion key of the datasets section (recipe IDs contain the plugin ID). */ +const DATASETS_SECTION = ":datasets"; + function isApplication(record: PluginRecord): boolean { return Boolean( record.enabled && @@ -122,9 +130,25 @@ export function ApplicationsDialog({ ); const [pendingParameters, setPendingParameters] = useState(null); + const [listCollapsed, setListCollapsed] = usePersistedBool( + APPLICATIONS_LIST_COLLAPSED_KEY, + false, + ); + const listId = useId(); + const accordionName = useId(); + /** Open accordion section per plugin, once the user changed it. */ + const [openSections, setOpenSections] = useState< + Record + >({}); useEffect(() => { - if (initialTarget) setSelectedId(initialTarget.pluginId); + if (!initialTarget) return; + setSelectedId(initialTarget.pluginId); + setOpenSections((current) => { + const next = { ...current }; + delete next[initialTarget.pluginId]; + return next; + }); }, [initialTarget]); useEffect(() => { @@ -368,6 +392,33 @@ export function ApplicationsDialog({ const datasets = selected?.examples.filter((example) => example.recipe_ids.length === 0) ?? []; + const listToggleLabel = listCollapsed + ? t("Show the application list") + : t("Hide the application list"); + const focusedRecipeId = selected?.recipes.some( + (recipe) => recipe.id === initialTarget?.recipeId, + ) + ? initialTarget!.recipeId + : null; + const defaultSection = + focusedRecipeId ?? + selected?.recipes[0]?.id ?? + (datasets.length > 0 ? DATASETS_SECTION : null); + const openSection = + selectedPluginId !== null && selectedPluginId in openSections + ? openSections[selectedPluginId] + : defaultSection; + const toggleSection = (section: string, isOpen: boolean) => { + if (selectedPluginId === null) return; + setOpenSections((current) => { + const known = selectedPluginId in current; + const previous = known ? current[selectedPluginId] : defaultSection; + const next = isOpen ? section : previous === section ? null : previous; + return known && next === previous + ? current + : { ...current, [selectedPluginId]: next }; + }); + }; return ( <> @@ -376,7 +427,9 @@ export function ApplicationsDialog({ role="dialog" aria-label={t("Applications")} > -
+
-
- {selected.recipes.length > 0 && ( -
-

{t("Methods")}

+ {(selected.recipes.length > 0 || datasets.length > 0) && ( +
{selected.recipes.map((recipe) => ( + toggleSection(recipe.id, isOpen) + } focused={recipe.id === initialTarget?.recipeId} disabled={busy} onRun={() => @@ -459,39 +531,54 @@ export function ApplicationsDialog({ } /> ))} -
- )} - - {datasets.length > 0 && ( -
-

{t("Datasets")}

- {datasets.map((example) => ( -
0 && ( +
+ toggleSection( + DATASETS_SECTION, + event.currentTarget.open, + ) + } > -
- {example.title} - {example.description && ( -

{example.description}

- )} + + {t("Datasets ({count})", { + count: datasets.length, + })} + +
+ {datasets.map((example) => ( +
+
+ {example.title} + {example.description && ( +

{example.description}

+ )} +
+ +
+ ))}
- -
- ))} -
+ + )} +
)} {selected.info?.documentation_url && ( diff --git a/src/locales/fr.json b/src/locales/fr.json index 5f1bfee..f041f02 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -310,7 +310,7 @@ "DataLab-Web is under active development and some features may still be incomplete or behave unexpectedly. If you run into an issue, please report it on the": "DataLab-Web est en développement actif et certaines fonctionnalités peuvent encore être incomplètes ou se comporter de manière inattendue. Si vous rencontrez un problème, veuillez le signaler sur le", "DataLab-Web on GitHub": "DataLab-Web sur GitHub", "DataLab-Web runs the Sigima scientific engine entirely in your browser. No data is uploaded — everything stays client-side.": "DataLab-Web exécute le moteur scientifique Sigima entièrement dans votre navigateur. Aucune donnée n'est envoyée — tout reste côté client.", - "Datasets": "Jeux de données", + "Datasets ({count})": "Jeux de données ({count})", "Deconvolution": "Déconvolution", "Degree": "Degré", "Delete": "Supprimer", @@ -491,6 +491,7 @@ "Help": "Aide", "Help & user guide": "Aide et guide utilisateur", "Hide AI Assistant": "Masquer l'assistant IA", + "Hide the application list": "Masquer la liste des applications", "High-pass filter": "Filtre passe-haut", "High-pass filter…": "Filtre passe-haut…", "hint": "indicative", @@ -626,7 +627,6 @@ "Memory reserved by the engine: {wasm}": "Mémoire réservée par le moteur : {wasm}", "Menu bar": "Barre de menus", "Metadata": "Métadonnées", - "Methods": "Méthodes", "Microsoft recommends adding the site as an exception": "Microsoft recommande d'ajouter le site comme exception", "min": "min", "Min": "Min", @@ -959,6 +959,7 @@ "Show pattern syntax help": "Afficher l'aide sur la syntaxe du motif", "Show raw array data": "Afficher les données brutes du tableau", "Show results overlay on plot": "Afficher les résultats sur le graphique", + "Show the application list": "Afficher la liste des applications", "Show toolbar": "Afficher la barre d'outils", "Show values": "Afficher les valeurs", "Show welcome page on startup": "Afficher la page d'accueil au démarrage", diff --git a/src/styles.css b/src/styles.css index 41694c2..99ceb94 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3259,12 +3259,20 @@ input { } .applications-dialog { + --applications-list-width: 230px; + display: flex; + flex-direction: column; width: min(860px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); - overflow: auto; + overflow: hidden; padding: 0; } +/* The application page keeps its width when the list is hidden. */ +.applications-dialog.list-collapsed { + width: min(calc(860px - var(--applications-list-width)), calc(100vw - 32px)); +} + .applications-window-layer { position: fixed; top: 64px; @@ -3277,6 +3285,7 @@ input { } .applications-window-layer .applications-dialog { + min-height: min(468px, 100%); max-height: 100%; background: var(--panel); border: 1px solid var(--border); @@ -3322,14 +3331,48 @@ input { .applications-layout { display: grid; - grid-template-columns: minmax(180px, 230px) minmax(0, 1fr); - min-height: 420px; + flex: 1 1 auto; + grid-template-columns: + minmax(180px, var(--applications-list-width)) auto + minmax(0, 1fr); + min-height: 0; +} + +.applications-dialog.list-collapsed .applications-layout { + grid-template-columns: auto minmax(0, 1fr); +} + +.applications-dialog.list-collapsed .applications-list { + display: none; } .applications-list { + overflow-y: auto; padding: 8px; + background: var(--panel-alt); +} + +.applications-list-toggle { + width: 14px; + padding: 0; + border: 0; border-right: 1px solid var(--border); + border-left: 1px solid var(--border); + border-radius: 0; background: var(--panel-alt); + color: var(--text-dim); + font-size: 18px; + line-height: 1; +} + +.applications-dialog.list-collapsed .applications-list-toggle { + border-left: 0; +} + +.applications-list-toggle:hover:not(:disabled) { + border-color: var(--border); + background: color-mix(in srgb, var(--accent) 16%, var(--panel)); + color: var(--text); } .applications-list button { @@ -3358,6 +3401,7 @@ input { .applications-content { min-width: 0; + overflow: auto; padding: 18px; } @@ -3394,17 +3438,6 @@ input { color: #b27a19; } -.application-section { - margin-top: 22px; -} - -.application-section h4 { - margin: 0 0 8px; - color: var(--text-dim); - font-size: 12px; - text-transform: uppercase; -} - .application-entry { min-height: 54px; padding: 10px 0; @@ -3426,14 +3459,81 @@ input { margin-top: 18px; } -.application-method { - padding: 12px 0; +.application-accordion { + margin-top: 18px; + border-bottom: 1px solid var(--border); +} + +.application-accordion > details { border-top: 1px solid var(--border); } +.application-accordion summary { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 6px; + cursor: pointer; + font-weight: 600; + list-style: none; +} + +.application-accordion summary::-webkit-details-marker { + display: none; +} + +.application-accordion summary::before { + content: "\25B8"; + flex: 0 0 10px; + color: var(--text-dim); +} + +.application-accordion details[open] > summary::before { + content: "\25BE"; +} + +.application-accordion summary:hover { + background: var(--panel-alt); +} + +.application-accordion summary:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.application-accordion-body { + padding: 0 6px 14px 24px; +} + +.application-method-version { + color: var(--text-dim); + font-size: 12px; + font-weight: normal; +} + +.application-readiness-dot { + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-dim); +} + +.application-readiness-dot.readiness-ready { + background: #2e8b57; +} + +.application-readiness-dot.readiness-warnings, +.application-readiness-dot.readiness-needs_assignment { + background: #b27a19; +} + +.application-readiness-dot.readiness-not_ready, +.application-readiness-dot.readiness-error { + background: #c0392b; +} + .application-method.focused { - margin-inline: -8px; - padding-inline: 8px; background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 var(--accent); } @@ -3583,11 +3683,14 @@ input { inset: 56px 8px 8px; } - .applications-layout { + .applications-layout, + .applications-dialog.list-collapsed .applications-layout { grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); } - .applications-list { + .applications-list, + .applications-dialog.list-collapsed .applications-list { display: flex; gap: 4px; overflow-x: auto; @@ -3595,6 +3698,10 @@ input { border-bottom: 1px solid var(--border); } + .applications-list-toggle { + display: none; + } + .applications-list button { flex: 0 0 180px; } diff --git a/tests/ts/components/ApplicationsDialog.test.tsx b/tests/ts/components/ApplicationsDialog.test.tsx index e7f9c03..dfcf5f6 100644 --- a/tests/ts/components/ApplicationsDialog.test.tsx +++ b/tests/ts/components/ApplicationsDialog.test.tsx @@ -1,7 +1,16 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ApplicationsDialog } from "../../../src/components/ApplicationsDialog"; +import { + APPLICATIONS_LIST_COLLAPSED_KEY, + ApplicationsDialog, +} from "../../../src/components/ApplicationsDialog"; import type { PluginExampleOpenResult, PluginRecord, @@ -501,7 +510,7 @@ describe("ApplicationsDialog", () => { ]); const { container } = renderDialog(); - expect(await screen.findByText("Datasets")).toBeInTheDocument(); + expect(await screen.findByText("Datasets (1)")).toBeInTheDocument(); const dataset = container.querySelector('[data-example-id="raw"]'); expect(dataset?.closest("[data-recipe-id]")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Open dataset" })); @@ -571,4 +580,105 @@ describe("ApplicationsDialog", () => { expect(runtimeMock.preparePluginRecipe).not.toHaveBeenCalled(); expect(runtimeMock.runPluginRecipe).not.toHaveBeenCalled(); }); + + describe("methods accordion", () => { + const TWO_METHODS: PluginRecord = { + ...APPLICATION, + recipes: [ + ...APPLICATION.recipes, + { + ...APPLICATION.recipes[0], + id: SECOND_RECIPE_ID, + title: "Dark current", + }, + ], + }; + + function methods(container: HTMLElement) { + return [RECIPE_ID, SECOND_RECIPE_ID].map( + (id) => + container.querySelector( + `[data-recipe-id="${id}"]`, + ) as HTMLDetailsElement, + ); + } + + beforeEach(() => { + runtimeMock.listPlugins.mockResolvedValue([TWO_METHODS]); + runtimeMock.assessPluginRecipes.mockResolvedValue({ + [RECIPE_ID]: READY, + [SECOND_RECIPE_ID]: READY, + }); + }); + + it("opens one method at a time and shows each readiness when closed", async () => { + const { container } = renderDialog(); + await screen.findByText("Dark current"); + const [first, second] = methods(container); + + expect(first.open).toBe(true); + expect(second.open).toBe(false); + expect(first.getAttribute("name")).toBeTruthy(); + expect(second.getAttribute("name")).toBe(first.getAttribute("name")); + await waitFor(() => + expect( + second.querySelector("summary [data-readiness]"), + ).toHaveAttribute("data-readiness", "ready"), + ); + expect( + within(second.querySelector("summary")!).getByRole("img", { + name: "Ready to run on the current selection", + }), + ).toBeInTheDocument(); + + fireEvent.click(second.querySelector("summary")!); + await waitFor(() => expect(first.open).toBe(false)); + expect(second.open).toBe(true); + }); + + it("opens the deep-linked method instead of the first one", async () => { + const { container } = renderDialog({ + initialTarget: { pluginId: PLUGIN_ID, recipeId: SECOND_RECIPE_ID }, + }); + await screen.findByText("Dark current"); + const [first, second] = methods(container); + + expect(first.open).toBe(false); + expect(second.open).toBe(true); + expect(second).toHaveClass("focused"); + }); + }); + + it("hides the application list from its strip and remembers it", async () => { + window.localStorage.removeItem(APPLICATIONS_LIST_COLLAPSED_KEY); + try { + const { container, unmount } = renderDialog(); + await screen.findByRole("heading", { name: "Camera Application" }); + const list = screen.getByRole("navigation", { name: "Applications" }); + const toggle = screen.getByRole("button", { + name: "Hide the application list", + }); + expect(toggle).toHaveAttribute("aria-controls", list.id); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(toggle); + expect(container.querySelector(".applications-dialog")).toHaveClass( + "list-collapsed", + ); + expect( + screen.getByRole("button", { name: "Show the application list" }), + ).toHaveAttribute("aria-expanded", "false"); + expect(window.localStorage.getItem(APPLICATIONS_LIST_COLLAPSED_KEY)).toBe( + "true", + ); + + unmount(); + const reopened = renderDialog(); + expect( + reopened.container.querySelector(".applications-dialog"), + ).toHaveClass("list-collapsed"); + } finally { + window.localStorage.removeItem(APPLICATIONS_LIST_COLLAPSED_KEY); + } + }); }); From acaa3b0d89cd99dcd45241dbfd49a38836ca75f7 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:23:11 +0200 Subject: [PATCH 3/5] feat(applications): guide users to set the metadata methods expect Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/components/ApplicationsDialog.tsx | 10 ++++ src/locales/fr.json | 1 + src/runtime/bootstrap.py | 15 +++++- src/runtime/dlw_plugins.py | 31 ++++++++++++- src/styles.css | 2 + src/utils/recipeInputs.ts | 3 ++ tests/python/test_applications.py | 27 +++++++++++ .../ts/components/ApplicationsDialog.test.tsx | 46 +++++++++++++++++++ 9 files changed, 133 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20885f9..b162ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - **Applications:** each method now shows the inputs it expects and a live status telling whether the current selection can be analyzed, and why not. _Run on selection…_ runs a ready method directly and asks for the input assignment only when it is ambiguous or invalid; the assignment dialog describes each input, flags objects lacking required metadata, and checks the chosen objects before _Continue_. Examples are listed under the methods they are designed for: _Try with this example_ opens the example and runs the method with prefilled parameters. One example may serve several methods, and examples designed for no method are listed as datasets. - **Applications window:** the strip between the application list and the application page hides the list, or shows it again; the window shrinks or grows accordingly and remembers this choice. The application page now scrolls on its own, so the list and the run status stay in view. - **Application methods:** the methods and datasets of an application now form an accordion. One section is open at a time (the deep-linked method, or else the first one), and a colored dot shows whether each method can run on the current selection without opening it. +- **Application metadata:** when required metadata are missing, the status of a method points to _Edit > Metadata > Add metadata…_, whose **Known keys** list now also offers the keys expected by application methods. The status is updated as soon as objects are modified, and long keys no longer widen parameter dialogs. - **Welcome page:** the "NEW" badge on the release notes entry has been removed. - **Histogram range editors:** generic parameter intervals now respect linked numeric constraints and read-only states. Brightness and contrast retains its specialized controls, including exact float64 window editing outside the source image's observed range. - **Guidata compatibility:** browser runtimes now require guidata 3.15 and use diff --git a/src/components/ApplicationsDialog.tsx b/src/components/ApplicationsDialog.tsx index 7021b0e..29d6c70 100644 --- a/src/components/ApplicationsDialog.tsx +++ b/src/components/ApplicationsDialog.tsx @@ -199,6 +199,15 @@ export function ApplicationsDialog({ const selectedPluginId = selected?.plugin_id ?? null; const recipeKey = selected?.recipes.map((recipe) => recipe.id).join("\n"); const candidateKey = candidateIds.join("\n"); + // Edits such as Add metadata change readiness without changing the selection + const [objectsRevision, setObjectsRevision] = useState(0); + useEffect( + () => + runtime?.onWorkspaceMutation(() => + setObjectsRevision((revision) => revision + 1), + ), + [runtime], + ); useEffect(() => { if (!runtime || !selectedPluginId || !recipeKey || busy) return; let cancelled = false; @@ -235,6 +244,7 @@ export function ApplicationsDialog({ busy, candidateKey, exampleContext, + objectsRevision, recipeKey, runtime, selectedPluginId, diff --git a/src/locales/fr.json b/src/locales/fr.json index 41c6629..0ffb8e2 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1069,6 +1069,7 @@ "Time deviation…": "Ecart-type temporel…", "Title": "Titre", "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", "Total variance": "Variance totale", "Total variance…": "Variance totale…", diff --git a/src/runtime/bootstrap.py b/src/runtime/bootstrap.py index a590b12..b3270d5 100644 --- a/src/runtime/bootstrap.py +++ b/src/runtime/bootstrap.py @@ -2045,8 +2045,11 @@ class AddMetadataParam( def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]: """Return the user-visible scalar metadata keys of *objs*, with an example - value (mirrors Qt's ``collect_metadata_keys``).""" + value, then the keys expected by application methods (mirrors Qt's + ``BaseDataPanel.get_known_metadata_keys``).""" + import dlw_plugins from sigima.config import _ + from sigima.objects import SignalObj from sigima.objects.base import ROI_KEY examples: dict[str, Any] = {} @@ -2058,7 +2061,15 @@ def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]: continue if isinstance(value, (str, bool, int, float, np.integer, np.floating)): examples[key] = value - return [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)] + keys = [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)] + kinds = {"signal" if isinstance(obj, SignalObj) else "image" for obj in objs} + if len(kinds) == 1: + keys.extend( + (key, description) + for key, description in dlw_plugins.declared_metadata_keys(kinds.pop()) + if key not in examples + ) + return keys def _build_metadata_values(objs: list[Any], param: Any) -> list[Any]: diff --git a/src/runtime/dlw_plugins.py b/src/runtime/dlw_plugins.py index 986a919..eecf163 100644 --- a/src/runtime/dlw_plugins.py +++ b/src/runtime/dlw_plugins.py @@ -34,7 +34,7 @@ # pattern that lets ``install_main`` swap the live bridge after HMR. # pylint: disable=import-error,global-statement,broad-exception-caught from datalab import registries # noqa: F401 # used elsewhere in module -from datalab.plugins import PluginBase, PluginRegistry # noqa: F401 +from datalab.plugins import PluginBase, PluginCapability, PluginRegistry # noqa: F401 from dlw_wheels import inspect_wheel PLUGINS_ROOT = "/home/pyodide/dlw_plugins" @@ -630,6 +630,34 @@ def get_plugin_class( return record.classes[0] +def declared_metadata_keys(object_type: str) -> list[tuple[str, str]]: + """Return the metadata keys expected by the methods of active applications. + + Args: + object_type: type of the objects carrying the metadata ("signal" or + "image") + + Returns: + ``(key, description)`` pairs, in declaration order, without duplicates + """ + keys: dict[str, str] = {} + for record in _RECORDS.values(): + instance = record.instance + if not record.enabled or instance is None or not record.classes: + continue + if instance.info is None or ( + PluginCapability.APPLICATION not in instance.info.capabilities + ): + continue + for recipe in record.classes[0].get_recipes(): + for slot in recipe.inputs: + if slot.object_type.value != object_type: + continue + for requirement in slot.metadata: + keys.setdefault(requirement.key, requirement.description) + return list(keys.items()) + + def slot_payload(slot: Any) -> dict[str, Any]: """Return the JSON description of one recipe input slot.""" return { @@ -738,6 +766,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]: "PLUGINS_ROOT", "PluginRecord", "add_change_listener", + "declared_metadata_keys", "discover_plugins_in_dir", "get_plugin_class", "inspect_plugin_wheel", diff --git a/src/styles.css b/src/styles.css index 76e2f6e..702284f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3879,6 +3879,8 @@ input { display: flex; align-items: center; gap: 6px; + /* Long choices (e.g. plugin metadata keys) must not widen the form. */ + min-width: 0; } .dataset-form-control > input, diff --git a/src/utils/recipeInputs.ts b/src/utils/recipeInputs.ts index 67a508f..b6d8ff9 100644 --- a/src/utils/recipeInputs.ts +++ b/src/utils/recipeInputs.ts @@ -100,6 +100,9 @@ export function formatReadiness( const reasons = assessment.issues.map((issue) => formatInputIssue(issue, slots), ); + if (assessment.issues.some((issue) => issue.code === "missing_metadata")) { + reasons.push(t("To set it, use Edit > Metadata > Add metadata…")); + } if (assessment.status !== "no_input") { reasons.push(...assessment.diagnostics.map(formatDiagnostic)); } diff --git a/tests/python/test_applications.py b/tests/python/test_applications.py index 5a87b21..6a3e19e 100644 --- a/tests/python/test_applications.py +++ b/tests/python/test_applications.py @@ -4,6 +4,8 @@ from __future__ import annotations +import dataclasses + import dlw_applications import dlw_plugins import numpy as np @@ -136,6 +138,31 @@ def _add_input_signal(bootstrap) -> str: return bootstrap.add_signal_from_arrays("Input", [0.0, 1.0], [1.0, 3.0]) +def test_add_metadata_suggests_keys_declared_by_applications( + applications_env, monkeypatch +) -> None: + bootstrap, record = applications_env + key = "plugin.org.example.generic-application.gain" + slot = RecipeInputSlot( + "source", + RecipeObjectType.SIGNAL, + RecipeCardinality.ONE, + metadata=(RecipeMetadataRequirement(key, "Detector gain"),), + ) + recipe = dataclasses.replace(GENERIC_RECIPE, inputs=(slot,)) + monkeypatch.setattr(GenericApplicationPlugin, "RECIPES", (recipe,)) + signal = bootstrap._MODEL.get(_add_input_signal(bootstrap)) + signal.metadata["operator"] = "Ada" + + assert bootstrap._metadata_key_suggestions([signal]) == [ + ("operator", "e.g. 'Ada'"), + (key, "Detector gain"), + ] + assert dlw_plugins.declared_metadata_keys("image") == [] + record.enabled = False + assert dlw_plugins.declared_metadata_keys("signal") == [] + + def test_prepare_recipe_returns_schema_and_unambiguous_binding( applications_env, ) -> None: diff --git a/tests/ts/components/ApplicationsDialog.test.tsx b/tests/ts/components/ApplicationsDialog.test.tsx index dfcf5f6..58eba0f 100644 --- a/tests/ts/components/ApplicationsDialog.test.tsx +++ b/tests/ts/components/ApplicationsDialog.test.tsx @@ -1,4 +1,5 @@ import { + act, fireEvent, render, screen, @@ -30,8 +31,11 @@ const runtimeMock = vi.hoisted(() => ({ resolvePluginRecipeChoices: vi.fn(), resolvePluginRecipeCallbacks: vi.fn(), resolvePluginRecipeActive: vi.fn(), + onWorkspaceMutation: vi.fn(), })); +const mutationListeners: ((name: string) => void)[] = []; + vi.mock("../../../src/runtime/RuntimeContext", () => ({ useRuntime: () => ({ runtime: runtimeMock as unknown as RuntimeApi }), })); @@ -224,6 +228,15 @@ function renderDialog( beforeEach(() => { vi.clearAllMocks(); + mutationListeners.length = 0; + runtimeMock.onWorkspaceMutation.mockImplementation( + (listener: (name: string) => void) => { + mutationListeners.push(listener); + return () => { + mutationListeners.splice(mutationListeners.indexOf(listener), 1); + }; + }, + ); runtimeMock.listPlugins.mockResolvedValue([APPLICATION]); runtimeMock.assessPluginRecipes.mockResolvedValue({ [RECIPE_ID]: READY }); runtimeMock.preparePluginRecipe.mockResolvedValue(PREPARATION); @@ -321,6 +334,39 @@ describe("ApplicationsDialog", () => { ); }); + it("re-assesses the methods when workspace objects change", async () => { + renderDialog(); + await screen.findByText("Ready to run on the current selection"); + const assessments = runtimeMock.assessPluginRecipes.mock.calls.length; + + runtimeMock.assessPluginRecipes.mockResolvedValueOnce({ + [RECIPE_ID]: { + status: "not_ready", + bindings: { frames: ["image-1", "image-2"] }, + issues: [ + { + code: "missing_metadata", + slot_id: "frames", + details: { key: "exposure_time", count: 2, titles: ["A", "B"] }, + }, + ], + diagnostics: [], + }, + }); + act(() => { + for (const listener of [...mutationListeners]) { + listener("delete_object_metadata"); + } + }); + + expect( + await screen.findByText("To set it, use Edit > Metadata > Add metadata…"), + ).toBeInTheDocument(); + expect(runtimeMock.assessPluginRecipes).toHaveBeenCalledTimes( + assessments + 1, + ); + }); + it("runs a ready method directly on the current selection", async () => { const onCommitted = vi.fn(); renderDialog({ onCommitted }); From ccad2a66d9e0bc9d9f2b34b387f248d84d157581 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:35:53 +0200 Subject: [PATCH 4/5] feat(applications): support plugin tools and instrument windows Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + doc/plugins.md | 13 +- src/App.tsx | 105 ++++++++ src/actions/registry.ts | 24 +- src/components/ApplicationsDialog.tsx | 75 +++++- src/components/InstrumentPlot.tsx | 99 +++++++ src/components/InstrumentWindow.tsx | 245 ++++++++++++++++++ src/locales/fr.json | 20 ++ ...ra_characterization-0.2.0-py3-none-any.whl | Bin 328574 -> 332561 bytes ...se_characterization-0.2.0-py3-none-any.whl | Bin 75123 -> 82275 bytes src/runtime/bundledPlugins.ts | 8 +- .../dlplugins/datalab/plugin_instruments.py | 115 ++++++++ .../dlplugins/datalab/plugin_resources.py | 80 ++++++ src/runtime/dlplugins/datalab/plugin_tools.py | 121 +++++++++ src/runtime/dlplugins/datalab/plugins.py | 113 +++++++- src/runtime/dlw_applications.py | 167 +++++++++++- src/runtime/dlw_plugins.py | 32 +++ src/runtime/runtime.ts | 146 +++++++++++ src/styles.css | 104 ++++++++ src/utils/pluginTools.ts | 45 ++++ tests/e2e/instrument_tools.spec.ts | 152 +++++++++++ tests/python/test_applications.py | 174 +++++++++++++ tests/python/test_camera_application.py | 49 ++++ tests/python/test_pulse_application.py | 40 +++ tests/ts/actions/registry.test.ts | 51 +++- .../ts/components/ApplicationsDialog.test.tsx | 50 ++++ tests/ts/components/InstrumentWindow.test.tsx | 163 ++++++++++++ 27 files changed, 2177 insertions(+), 15 deletions(-) create mode 100644 src/components/InstrumentPlot.tsx create mode 100644 src/components/InstrumentWindow.tsx create mode 100644 src/runtime/dlplugins/datalab/plugin_instruments.py create mode 100644 src/runtime/dlplugins/datalab/plugin_resources.py create mode 100644 src/runtime/dlplugins/datalab/plugin_tools.py create mode 100644 src/utils/pluginTools.ts create mode 100644 tests/e2e/instrument_tools.spec.ts create mode 100644 tests/ts/components/InstrumentWindow.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b162ed4..91c1606 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 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 13f20c3ae0fbe011cf89d016b0d15fdba2f487db..19f909eedcd0ec3cd4d49b4c140f130edee1a46a 100644 GIT binary patch delta 15832 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 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 6395d628ef023c567db04cd927ab0963476474a6..353d9c7acece08a70414e38c04c583d662347465 100644 GIT binary patch delta 16563 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 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( ({ + 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(); + }); +}); From aa393d58825ff4e38c84f553d4f37351044ceb94 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:36:06 +0200 Subject: [PATCH 5/5] fix(forms): hide the field name of checkboxes that have their own text Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/components/DataSetForm/DataSetForm.tsx | 6 ++++- tests/ts/components/DataSetForm.test.tsx | 27 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c1606..21474ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,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/src/components/DataSetForm/DataSetForm.tsx b/src/components/DataSetForm/DataSetForm.tsx index f0507b7..659208f 100644 --- a/src/components/DataSetForm/DataSetForm.tsx +++ b/src/components/DataSetForm/DataSetForm.tsx @@ -471,7 +471,11 @@ interface FieldRowProps { function FieldRow(props: FieldRowProps) { const { name, prop } = props; - const label = (prop["x-guidata-label"] as string | undefined) ?? name; + // Like guidata on Desktop, a ``BoolItem`` without a label only shows + // the text of its checkbox. + const label = + (prop["x-guidata-label"] as string | undefined) ?? + (prop["x-guidata-kind"] === "bool" && prop["x-guidata-text"] ? "" : name); const help = prop.description as string | undefined; const unit = prop["x-guidata-unit"] as string | undefined; const activeOverrides = useContext(ActiveOverridesContext); diff --git a/tests/ts/components/DataSetForm.test.tsx b/tests/ts/components/DataSetForm.test.tsx index a6b3e11..2659527 100644 --- a/tests/ts/components/DataSetForm.test.tsx +++ b/tests/ts/components/DataSetForm.test.tsx @@ -119,6 +119,33 @@ describe("DataSetForm display callbacks", () => { }); }); +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();