Skip to content

Commit d98d1e4

Browse files
committed
fix(forms): support dynamic active-state grayout in all guidata forms
1 parent 7e9d748 commit d98d1e4

12 files changed

Lines changed: 558 additions & 18 deletions

File tree

‎src/App.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ interface PendingAnalysis {
159159
funcId: string;
160160
label: string;
161161
schema: SchemaWithValues;
162+
kind: "signal" | "image";
162163
}
163164

164165
/** Persist a numeric layout dimension to localStorage so it survives a
@@ -1538,6 +1539,7 @@ export default function App() {
15381539
funcId,
15391540
label: entry?.label ?? funcId,
15401541
schema: schema as SchemaWithValues,
1542+
kind: treeKind === "image" ? "image" : "signal",
15411543
});
15421544
},
15431545
[
@@ -3691,6 +3693,9 @@ export default function App() {
36913693
currentValues,
36923694
)
36933695
}
3696+
resolveActive={(currentValues) =>
3697+
runtime.resolveFeatureActive(pending.feature.id, currentValues)
3698+
}
36943699
onSubmit={handleSubmitParams}
36953700
onCancel={() => setPending(null)}
36963701
/>
@@ -3720,6 +3725,12 @@ export default function App() {
37203725
currentValues,
37213726
)
37223727
}
3728+
resolveActive={(currentValues) =>
3729+
runtime.resolveFeatureActive(
3730+
pendingProfile.feature.id,
3731+
currentValues,
3732+
)
3733+
}
37233734
onSubmit={handleSubmitProfile}
37243735
onCancel={() => setPendingProfile(null)}
37253736
/>
@@ -3730,6 +3741,16 @@ export default function App() {
37303741
payload={pendingAnalysis.schema}
37313742
onSubmit={handleSubmitAnalysisParams}
37323743
onCancel={() => setPendingAnalysis(null)}
3744+
resolveActive={
3745+
runtime
3746+
? (currentValues) =>
3747+
runtime.resolveAnalysisActive(
3748+
pendingAnalysis.kind,
3749+
pendingAnalysis.funcId,
3750+
currentValues,
3751+
)
3752+
: undefined
3753+
}
37333754
/>
37343755
)}
37353756
{pendingImageGrid && (
@@ -3738,6 +3759,12 @@ export default function App() {
37383759
payload={pendingImageGrid.schema}
37393760
onSubmit={handleSubmitImageGrid}
37403761
onCancel={() => setPendingImageGrid(null)}
3762+
resolveActive={
3763+
runtime
3764+
? (currentValues) =>
3765+
runtime.resolveImageGridActive(currentValues)
3766+
: undefined
3767+
}
37413768
/>
37423769
)}
37433770
{pendingRoiGrid && imageData && (

‎src/components/DataSetDialog.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ interface Props {
2626
itemName: string,
2727
currentValues: Record<string, unknown>,
2828
) => Promise<Record<string, unknown>>;
29+
resolveActive?: (
30+
currentValues: Record<string, unknown>,
31+
) => Promise<Record<string, boolean>>;
2932
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
3033
onCancel: () => void;
3134
}
@@ -36,6 +39,7 @@ export function DataSetDialog(props: Props) {
3639
payload,
3740
resolveChoices,
3841
resolveCallbacks,
42+
resolveActive,
3943
onSubmit,
4044
onCancel,
4145
} = props;
@@ -75,6 +79,7 @@ export function DataSetDialog(props: Props) {
7579
onChange={setValues}
7680
resolveChoices={resolveChoices}
7781
resolveCallbacks={resolveCallbacks}
82+
resolveActive={resolveActive}
7883
/>
7984
{error && <div className="error">{error}</div>}
8085
<div className="actions">

‎src/components/DataSetForm/DataSetForm.tsx‎

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@
1515
* Falls back to a flat property order when that key is missing.
1616
*/
1717

18-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18+
import {
19+
createContext,
20+
useCallback,
21+
useContext,
22+
useEffect,
23+
useMemo,
24+
useRef,
25+
useState,
26+
} from "react";
1927
import DOMPurify from "dompurify";
2028
import type { DynamicChoice, JsonSchema } from "../../runtime/runtime";
2129
import { t } from "../../i18n/translate";
@@ -27,6 +35,13 @@ import { ArrayEditorDialog, normalizeToMatrix } from "../ArrayEditorDialog";
2735

2836
type Values = Record<string, unknown>;
2937

38+
/** Live ``display.active`` overrides keyed by item name. Populated by
39+
* ``resolveActive`` round-trips when the form contains items whose active
40+
* state is dynamic (``x-guidata-active-dynamic``). An entry of ``false``
41+
* greys out the corresponding field; ``true`` re-enables it; a missing
42+
* entry falls back to the static ``x-guidata-active`` baked in the schema. */
43+
const ActiveOverridesContext = createContext<Record<string, boolean>>({});
44+
3045
/** Sanitise a guidata label before injecting it as HTML. Labels may
3146
* legitimately carry simple inline markup (``<b>``, ``<sub>``, units
3247
* like ``m<sup>2</sup>``), so we keep an HTML profile but strip any
@@ -53,6 +68,13 @@ export interface DataSetFormProps {
5368
itemName: string,
5469
currentValues: Values,
5570
) => Promise<Values>;
71+
/** Resolver for guidata ``display.active`` props (when the form has at
72+
* least one item carrying ``x-guidata-active-dynamic``). Runs the
73+
* ``active`` callables Python-side and returns ``{itemName: boolean}``
74+
* for every item, mirroring the Qt ``update_widgets`` enable/disable
75+
* cascade (e.g. ``BlobOpenCVParam.filter_by_circularity`` gating
76+
* ``min_circularity``). */
77+
resolveActive?: (currentValues: Values) => Promise<Record<string, boolean>>;
5678
}
5779

5880
// Layout node as emitted by the Python side. Either a property name
@@ -71,6 +93,7 @@ type LayoutNode = LayoutLeaf | LayoutContainer;
7193

7294
export function DataSetForm(props: DataSetFormProps) {
7395
const { schema, values, onChange, resolveChoices, resolveCallbacks } = props;
96+
const { resolveActive } = props;
7497
const properties = useMemo(
7598
() => (schema.properties as Record<string, JsonSchema>) ?? {},
7699
[schema],
@@ -80,10 +103,43 @@ export function DataSetForm(props: DataSetFormProps) {
80103
(schema["x-guidata-property-order"] as string[] | undefined) ??
81104
Object.keys(properties);
82105

106+
// Whether any item's ``active`` state is dynamic. When true, every edit
107+
// may flip a sibling's enabled state (e.g. toggling
108+
// ``filter_by_circularity`` enables ``min_circularity``), so we re-run the
109+
// ``active`` callables Python-side after each change.
110+
const hasDynamicActive = useMemo(
111+
() =>
112+
Object.values(properties).some(
113+
(p) => p["x-guidata-active-dynamic"] === true,
114+
),
115+
[properties],
116+
);
117+
118+
// Live ``display.active`` overrides keyed by item name (see context doc).
119+
const [activeOverrides, setActiveOverrides] = useState<
120+
Record<string, boolean>
121+
>({});
122+
83123
// Monotonic counter guarding against out-of-order callback responses:
84124
// the last edit wins, so a slow Python round-trip can never clobber a
85125
// newer one (cf. the ``cancelled`` flag in ``useChoices``).
86126
const callbackSeq = useRef(0);
127+
const activeSeq = useRef(0);
128+
129+
// Resolve the initial active state once (covers dynamic items whose
130+
// default state depends on sibling defaults). Subsequent edits refresh it
131+
// through ``setValue`` below.
132+
useEffect(() => {
133+
if (!hasDynamicActive || !resolveActive) return;
134+
const seq = ++activeSeq.current;
135+
resolveActive(values).then((map) => {
136+
if (seq !== activeSeq.current) return;
137+
if (map) setActiveOverrides(map);
138+
});
139+
// Run only on mount / when the resolver or schema shape changes; per-edit
140+
// refresh is handled in ``setValue``.
141+
// eslint-disable-next-line react-hooks/exhaustive-deps
142+
}, [hasDynamicActive, resolveActive]);
87143

88144
const setValue = useCallback(
89145
(name: string, value: unknown) => {
@@ -99,23 +155,39 @@ export function DataSetForm(props: DataSetFormProps) {
99155
}
100156
});
101157
}
158+
if (hasDynamicActive && resolveActive) {
159+
const seq = ++activeSeq.current;
160+
resolveActive(newValues).then((map) => {
161+
if (seq !== activeSeq.current) return;
162+
if (map) setActiveOverrides(map);
163+
});
164+
}
102165
},
103-
[onChange, values, properties, resolveCallbacks],
166+
[
167+
onChange,
168+
values,
169+
properties,
170+
resolveCallbacks,
171+
hasDynamicActive,
172+
resolveActive,
173+
],
104174
);
105175

106176
return (
107-
<div className="dataset-form">
108-
{layout.map((node, idx) => (
109-
<LayoutNodeView
110-
key={idx}
111-
node={node}
112-
properties={properties}
113-
values={values}
114-
setValue={setValue}
115-
resolveChoices={resolveChoices}
116-
/>
117-
))}
118-
</div>
177+
<ActiveOverridesContext.Provider value={activeOverrides}>
178+
<div className="dataset-form">
179+
{layout.map((node, idx) => (
180+
<LayoutNodeView
181+
key={idx}
182+
node={node}
183+
properties={properties}
184+
values={values}
185+
setValue={setValue}
186+
resolveChoices={resolveChoices}
187+
/>
188+
))}
189+
</div>
190+
</ActiveOverridesContext.Provider>
119191
);
120192
}
121193

@@ -251,12 +323,25 @@ function FieldRow(props: FieldRowProps) {
251323
const label = (prop["x-guidata-label"] as string | undefined) ?? name;
252324
const help = prop.description as string | undefined;
253325
const unit = prop["x-guidata-unit"] as string | undefined;
326+
const activeOverrides = useContext(ActiveOverridesContext);
254327
// An item is non-editable when it is read-only (e.g. ``set_computed``)
255328
// or when its ``display.active`` resolved to ``False`` (e.g.
256-
// ``ArithmeticParam.operation``, a preview field updated by callbacks).
257-
const readOnly = prop.readOnly === true || prop["x-guidata-active"] === false;
329+
// ``ArithmeticParam.operation``, a preview field updated by callbacks, or
330+
// ``BlobOpenCVParam.min_circularity`` while its filter group is off). A
331+
// live override (from ``resolveActive``) wins over the static schema flag.
332+
const override = activeOverrides[name];
333+
const readOnly =
334+
prop.readOnly === true ||
335+
override === false ||
336+
(override === undefined && prop["x-guidata-active"] === false);
258337
return (
259-
<div className="dataset-form-row">
338+
<div
339+
className={
340+
readOnly
341+
? "dataset-form-row dataset-form-row-disabled"
342+
: "dataset-form-row"
343+
}
344+
>
260345
<label
261346
className="dataset-form-label"
262347
title={help}

‎src/components/DialogBridge.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export function DialogBridge() {
6666
payload={payload}
6767
onSubmit={(values) => finish(values)}
6868
onCancel={() => finish(null)}
69+
resolveActive={
70+
runtime
71+
? (currentValues) => runtime.resolveBridgeActive(currentValues)
72+
: undefined
73+
}
6974
/>
7075
);
7176
}

‎src/components/ProfileDefinitionDialog.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ interface Props {
5252
itemName: string,
5353
currentValues: Record<string, unknown>,
5454
) => Promise<Record<string, unknown>>;
55+
resolveActive?: (
56+
currentValues: Record<string, unknown>,
57+
) => Promise<Record<string, boolean>>;
5558
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
5659
onCancel: () => void;
5760
}
@@ -259,6 +262,7 @@ export function ProfileDefinitionDialog(props: Props) {
259262
imageData,
260263
resolveChoices,
261264
resolveCallbacks,
265+
resolveActive,
262266
onSubmit,
263267
onCancel,
264268
} = props;
@@ -394,6 +398,7 @@ export function ProfileDefinitionDialog(props: Props) {
394398
onChange={setValues}
395399
resolveChoices={resolveChoices}
396400
resolveCallbacks={resolveCallbacks}
401+
resolveActive={resolveActive}
397402
/>
398403
</div>
399404
</div>

‎src/runtime/_guidata_jsonschema_shim.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,17 @@ def dataset_to_schema_with_values(instance: gdt.DataSet) -> dict[str, Any]:
193193
"""
194194
schema = dataset_to_schema(type(instance))
195195
_apply_display_callbacks(instance)
196+
# Bake the per-instance ``display.active`` state into the schema so the
197+
# very first render already greys out inactive widgets (e.g. the
198+
# blob-detection ``min_circularity`` gated by ``filter_by_circularity``)
199+
# without waiting for a frontend round-trip. Only inactive items are
200+
# flagged; active items stay editable by default, and dynamic ones keep
201+
# their ``x-guidata-active-dynamic`` marker so the frontend re-evaluates
202+
# them via :func:`resolve_dataset_active` on every edit.
203+
properties = schema.get("properties", {})
204+
for name, is_active in resolve_dataset_active(instance).items():
205+
if not is_active and name in properties:
206+
properties[name]["x-guidata-active"] = False
196207
values: dict[str, Any] = {}
197208
for item in instance.get_items():
198209
if isinstance(item, (gdt.BeginGroup, gdt.EndGroup, gdt.SeparatorItem)):
@@ -317,6 +328,7 @@ def resolve_dataset_active(instance: gdt.DataSet) -> dict[str, bool]:
317328
Mapping ``{item_name: bool}``. Items without a name (separators,
318329
end-of-group markers, ...) are skipped.
319330
"""
331+
_sync_store_props(instance)
320332
result: dict[str, bool] = {}
321333
for item in instance.get_items():
322334
name = item.get_name()
@@ -330,6 +342,33 @@ def resolve_dataset_active(instance: gdt.DataSet) -> dict[str, bool]:
330342
return result
331343

332344

345+
def _sync_store_props(instance: gdt.DataSet) -> None:
346+
"""Replay the Qt ``do_store`` step for every ``display.store`` item.
347+
348+
guidata's ``store`` / :class:`~guidata.dataset.datatypes.ValueProp`
349+
mechanism (used e.g. by ``BlobOpenCVParam.filter_by_circularity`` to
350+
gate ``min_circularity`` / ``max_circularity``) holds its value on the
351+
*property object* itself, not on the dataset instance. In the Qt UI
352+
that value is pushed by the checkbox widget's ``do_store`` callback
353+
whenever the user toggles it. Headless schema generation never builds
354+
those widgets, so the stored value stays at its class-level default and
355+
every ``active=ValueProp(...)`` sibling would resolve against stale
356+
state. Replaying ``store.set(instance, item, value)`` here keeps the
357+
non-Qt ``active`` resolution faithful to the Qt UI.
358+
"""
359+
for item in instance.get_items():
360+
try:
361+
store = item.get_prop("display", "store", None)
362+
except Exception: # pylint: disable=broad-except
363+
store = None
364+
if store is None:
365+
continue
366+
try:
367+
store.set(instance, item, item.get_value(instance))
368+
except Exception: # pylint: disable=broad-except
369+
pass
370+
371+
333372
# ---------------------------------------------------------------------------
334373
# Layout traversal (groups & tabs)
335374
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)