diff --git a/CHANGELOG.md b/CHANGELOG.md index d31d268ae..711190e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,16 @@ API surface). ### Added +- **`SlotRegistry.byKind(kind, component)` — one host control per kind of + control.** The JSON type `byType` keys on does not identify a control: a + `Quantity` and a nested object are both `"object"`, a `Choice` is + `"integer"`, an enum and a `Timestamp` are `"string"`. Field descriptors now + carry `kind` (`quantity`, `choice`, `enum`, `datetime`, `date`, `boolean`, + `integer`, `number`, `string`, `array`, `objectArray`, `object`), and + `resolve()` takes it as an optional sixth argument, consulted after unit and + before type. See `docs/spec/forms/forms.md`, "Theming / component-override + registry" (fixes #812). + - **A locale numeric entry accepts an explicit `+`.** `morph::render::normalizeLocaleNumber` had no notion of a positive sign: a leading `+` fell through to the "any other character is malformed" arm, so diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index e30e7fdd2..66eb2bbfc 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1404,9 +1404,11 @@ forking the renderer: - **`SlotRegistry` (QML type, module `MorphForms`, entirely client-side).** A lookup a host app populates at startup: `byField(action, field, component)`, `byWidget(xWidget, component)`, `byUnit(unitAscii, component)`, - `byType(jsonType, component)`, and `resolve(action, field, xWidget, - unitAscii, jsonType)`, which returns the highest-priority match or `null`. - Resolution order is **field → `x-widget` → unit → type → built-in default**. + `byKind(kind, component)`, `byType(jsonType, component)`, and + `resolve(action, field, xWidget, unitAscii, jsonType, kind)`, which returns + the highest-priority match or `null` (`kind` is optional; the five-argument + call resolves as before). Resolution order is **field → `x-widget` → unit → + kind → type → built-in default**. `DynamicForm` gains a `slotRegistry` property (`null` by default — no behavior change for an app that never sets it); when a field resolves to a registered `Component`, `DynamicForm` loads it via a `Loader` and hides its @@ -1424,6 +1426,31 @@ forking the renderer: plain objects mutated in place, which does not by itself notify a binding that already read them. +**`byKind` — one host control per kind of control.** The JSON type does not +name the control a field needs: a `Quantity` and a nested object are both +`"object"`, a `Choice` is `"integer"`, a closed set and a `Timestamp` are +`"string"`. Every field descriptor therefore carries `kind`, the control this +renderer would draw, decided in the order its encoder is chosen: + +| `kind` | Member (schema shape) | +|---|---| +| `objectArray` | `std::vector` (`array` whose `items` are an object) | +| `array` | any other `std::vector` | +| `enum` | a closed set (`oneOf` of `const`s, or `enum`) | +| `choice` | a `Choice` (`x-optionsAction`) | +| `datetime` | a `Timestamp` (`format: "date-time"`) | +| `date` | `format: "date"` (drawn as a plain text field by the built-in renderer) | +| `quantity` | a `Quantity`, or any property with `x-decimalPlaces` | +| `integer` / `boolean` / `number` | `type` of that name | +| `object` | a nested aggregate (unrepresentable without a slot) | +| `string` | everything else | + +A host registers one kit component per kind (`byKind("quantity", …)`, +`byKind("choice", …)`, …); a field, `x-widget` or unit registration still wins, +and `byType` remains the fallback for a kind with none. Pinned by +`src/qt/forms/tests/tst_SlotRegistryByKind.qml`; removing the tier from +`resolve()` reddens 4 of its 5 cases. + The registry never appears in the schema or on the wire — two renderers of the same schema may register different slots. This is the "escape hatch always available" design principle ([above](#design-principle-infer-by-default-declare-to-override)) diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index a820244b2..b7fc70a14 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -338,6 +338,37 @@ Frame { return "" } + // The renderer kind of a resolved property: the control it gets, which + // the JSON type alone does not say (a Quantity and a nested object are both + // "object", a Choice is "integer", an enum and a date-time are "string"). + // Asked in the order fieldJsonLiteral encodes in, so the kind names the + // encoder the field actually gets. + function fieldKind(p, types, dp, optionsAction, isEnum) { + if (types.indexOf("array") !== -1) { + const itemTypes = jsonTypes(resolveProp(p.items)) + return itemTypes.indexOf("object") !== -1 ? "objectArray" : "array" + } + if (isEnum) + return "enum" + if (optionsAction !== undefined) + return "choice" + if (p.format === "date-time") + return "datetime" + if (p.format === "date") + return "date" + if (dp !== undefined) + return "quantity" + if (types.indexOf("integer") !== -1) + return "integer" + if (types.indexOf("boolean") !== -1) + return "boolean" + if (types.indexOf("number") !== -1) + return "number" + if (types.indexOf("object") !== -1) + return "object" + return "string" + } + // Value/label pairs for a property that states a **closed set of values** // outright, or [] for one that does not. Two spellings, both handled: // @@ -609,6 +640,9 @@ Frame { // to "" and SlotRegistry.resolve()'s byWidget tier never // matches. xWidget: opt(widget, ""), + // The control this renderer would draw, named for + // SlotRegistry.byKind (see fieldKind). + kind: fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0), unitAscii: opt(extUnits.unitAscii, ""), jsonType: types.length > 0 ? types[0] : "" } @@ -1708,7 +1742,8 @@ Frame { ? form.slotRegistry.resolve(form.actionType, fieldColumn.modelData.name, fieldColumn.modelData.xWidget, fieldColumn.modelData.unitAscii, - fieldColumn.modelData.jsonType) + fieldColumn.modelData.jsonType, + fieldColumn.modelData.kind) : null Loader { diff --git a/src/qt/forms/qml/SlotRegistry.qml b/src/qt/forms/qml/SlotRegistry.qml index 0ac835dbe..d704480f3 100644 --- a/src/qt/forms/qml/SlotRegistry.qml +++ b/src/qt/forms/qml/SlotRegistry.qml @@ -5,8 +5,8 @@ // registry" / docs/planned/gui_renderer_toolkit.md). Entirely client-side: a // slot is a QML Component the host app registers; it never appears in the // schema or on the wire, and two renderers of the same schema may register -// different slots. DynamicForm consults byField/byWidget/byUnit/byType in -// that priority order and falls back to its own built-in control on a miss. +// different slots. DynamicForm consults byField/byWidget/byUnit/byKind/byType +// in that priority order and falls back to its own built-in control on a miss. import QtQuick @@ -16,6 +16,7 @@ QtObject { property var _byField: ({}) // "action field" -> Component property var _byWidget: ({}) // x-widget id -> Component property var _byUnit: ({}) // unitAscii -> Component + property var _byKind: ({}) // renderer kind ("quantity", "choice", ...) -> Component property var _byType: ({}) // JSON type ("integer", "string", ...) -> Component // Bumped on every by*() registration. `_byField`/`_byWidget`/`_byUnit`/ @@ -51,6 +52,18 @@ QtObject { revision++ } + /// Registers @p component for every field of the given renderer kind -- + /// the control DynamicForm would otherwise draw, as its field + /// descriptor's `kind` names it: "quantity", "choice", "enum", "datetime", + /// "date", "boolean", "integer", "number", "string", "array", + /// "objectArray" or "object". Unlike the JSON type, a kind tells a + /// Quantity from a nested object, a Choice from an integer, and an enum or + /// a date-time from free text. + function byKind(kind, component) { + _byKind[kind] = component + revision++ + } + /// Registers @p component for every field of the given JSON Schema /// `type` (e.g. "integer", "string"). function byType(jsonType, component) { @@ -58,15 +71,18 @@ QtObject { revision++ } - /// Resolution order: field -> x-widget -> unit -> type -> null + /// Resolution order: field -> x-widget -> unit -> kind -> type -> null /// (built-in). Returns the first matching Component, or null on a total - /// miss (DynamicForm then renders its own built-in control). - function resolve(action, field, xWidget, unitAscii, jsonType) { + /// miss (DynamicForm then renders its own built-in control). @p kind is + /// optional, so a caller passing the five original arguments resolves + /// exactly as before. + function resolve(action, field, xWidget, unitAscii, jsonType, kind) { registry.revision const key = action + " " + field if (_byField[key] !== undefined) return _byField[key] if (xWidget !== "" && _byWidget[xWidget] !== undefined) return _byWidget[xWidget] if (unitAscii !== "" && _byUnit[unitAscii] !== undefined) return _byUnit[unitAscii] + if (kind !== undefined && kind !== "" && _byKind[kind] !== undefined) return _byKind[kind] if (jsonType !== "" && _byType[jsonType] !== undefined) return _byType[jsonType] return null } diff --git a/src/qt/forms/tests/tst_SlotRegistryByKind.qml b/src/qt/forms/tests/tst_SlotRegistryByKind.qml new file mode 100644 index 000000000..b212336be --- /dev/null +++ b/src/qt/forms/tests/tst_SlotRegistryByKind.qml @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// SlotRegistry.byKind: one host control per renderer kind. +// +// The JSON type a field carries does not name the control it needs -- a +// Quantity and a nested object are both "object", a Choice is "integer", an +// enum and a date-time are "string" -- so a host registering one component per +// kind of control had to go field by field. Each case below builds the schema +// shape schemaJson() emits for one member kind and asserts both halves: the +// descriptor's `kind`, and that a byKind slot (not the byType one registered +// for the same JSON type) is what the form loads. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "SlotRegistryByKind" + visible: true + + property var kindSchema: ({ + "$defs": { + "double": { type: "number" }, + "Row": { type: "object", properties: { a: { type: "integer", "x-order": 0 } }, required: ["a"] }, + "Sub": { type: "object", properties: { b: { type: "string", "x-order": 0 } } } + }, + properties: { + mass: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + ExtUnits: { unitAscii: "kg", unitUnicode: "kg" }, "x-decimalPlaces": 2, "x-order": 0 + }, + sample: { type: "integer", "x-optionsAction": "ListSamples", "x-order": 1 }, + role: { type: "string", oneOf: [{ title: "A", const: "A" }, { title: "B", const: "B" }], "x-order": 2 }, + takenAt: { type: "string", format: "date-time", "x-order": 3 }, + day: { type: "string", format: "date", "x-order": 4 }, + done: { type: "boolean", "x-order": 5 }, + count: { type: "integer", "x-order": 6 }, + ratio: { "$ref": "#/$defs/double", "x-order": 7 }, + note: { type: "string", "x-order": 8 }, + tags: { type: "array", items: { type: "string" }, "x-order": 9 }, + rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 10 }, + sub: { "$ref": "#/$defs/Sub", "x-order": 11 } + }, + required: [] + }) + + readonly property var expectedKinds: ({ + mass: "quantity", sample: "choice", role: "enum", takenAt: "datetime", day: "date", + done: "boolean", count: "integer", ratio: "number", note: "string", tags: "array", + rows: "objectArray", sub: "object" + }) + + // A slot that says which kind it was registered for. + Component { + id: kindSlot + Item { + property var field + property var setValue + property string registeredKind + objectName: "kindSlot_" + (field ? field.name : "") + } + } + + Component { + id: typeSlot + Item { + property var field + property var setValue + objectName: "typeSlot_" + (field ? field.name : "") + } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + Component { + id: formComponent + DynamicForm { actionType: "T_Kind"; controller: null; schema: testCase.kindSchema } + } + + function test_every_field_names_its_kind() { + const form = createTemporaryObject(formComponent, testCase) + for (const name in expectedKinds) + compare(form.fieldByName[name].kind, expectedKinds[name], name) + } + + function test_resolution_order_puts_kind_between_unit_and_type() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byType("object", typeSlot) + registry.byKind("quantity", kindSlot) + compare(registry.resolve("T", "mass", "", "", "object", "quantity"), kindSlot) + registry.byUnit("kg", typeSlot) + compare(registry.resolve("T", "mass", "", "kg", "object", "quantity"), typeSlot) + // The five-argument form still resolves as before. + compare(registry.resolve("T", "other", "", "", "object"), typeSlot) + } + + function test_a_kind_slot_is_loaded_for_every_field_of_that_kind() { + const registry = createTemporaryObject(registryComponent, testCase) + // byType for each JSON type in play: a kind must beat it. + const jsonTypes = ["object", "integer", "string", "boolean", "number", "array"] + for (let t = 0; t < jsonTypes.length; ++t) + registry.byType(jsonTypes[t], typeSlot) + for (const name in expectedKinds) + registry.byKind(expectedKinds[name], kindSlot) + const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry }) + for (const name in expectedKinds) { + verify(findChild(form, "kindSlot_" + name) !== null, name) + compare(findChild(form, "typeSlot_" + name), null, name) + } + } + + function test_an_unregistered_kind_falls_through_to_the_type() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byType("integer", typeSlot) + registry.byKind("choice", kindSlot) + const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry }) + verify(findChild(form, "kindSlot_sample") !== null) + verify(findChild(form, "typeSlot_count") !== null) + } + + function test_a_kind_slot_drives_the_form() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byKind("quantity", kindSlot) + const form = createTemporaryObject(formComponent, testCase, { slotRegistry: registry }) + findChild(form, "kindSlot_mass").setValue("1.25") + compare(form.previewLine, '{"mass":{"num":125,"den":100,"dp":2}}') + } +}