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 });