morph::forms::Multiline and morph::forms::Ranged<Min, Max, Step> are thin
field-type wrappers, in the same family as Choice (choice.md):
each carries a rendering control preference in the C++ type itself, and
the schema surfaces that preference as an x-* annotation a renderer can act
on. Neither wrapper changes what travels on the wire — Multiline is a plain
string, Ranged a nullable number, exactly as if the field had been declared
unwrapped.
For the residual cases a type cannot express — chiefly forcing a specific
control on a plain type, or choosing radio buttons over a combo box for a
Choice — an action names the field in the same static constexpr fieldMetadata array the field-metadata feature uses for labels, help text,
and the rest (forms.md, "Field metadata"),
whose entry carries a non-empty widget; morph::forms reads that override
structurally (duck-typed on .field / .widget), so this mechanism works
with any matching descriptor type without this header naming or including it.
Multiline— structureRanged<Min, Max, Step>— structure- Empty state
- Wire and schema
- Schema representation
- Widget override:
fieldMetadata - API reference
- Design decisions
- Usage example
- Failure modes
- Limitations
- Cross-references
struct Multiline {
std::string value;
// ...
};A Multiline field is a std::string that should be edited as a
multi-line text area rather than the single-line field every unwrapped
std::string gets by default. The struct carries exactly one payload member,
value; the glz::meta specialisation reflects it directly, so the type
serialises as a plain JSON string, indistinguishable on the wire from an
unwrapped std::string.
template <auto Min, auto Max, auto Step = 1>
struct Ranged {
std::optional<decltype(Min)> value;
// ...
};| Template parameter | Purpose |
|---|---|
Min |
Inclusive lower bound of the slider's control track. |
Max |
Inclusive upper bound of the control track (must be greater than Min). |
Step |
Track increment (defaults to 1; must be strictly positive). |
Min, Max, and Step must be the same arithmetic type (all int, all
double, …) — a Ranged mixing an int Min/Max with the default Step
(itself an int literal, 1) compiles; mixing an int Min/Max with a
double Step (or vice versa) does not, so a floating-point Ranged must
name its Step explicitly (e.g. Ranged<0.5, 2.5, 0.5>, never
Ranged<0.5, 2.5>).
A Ranged field is a bounded numeric edited as a slider, with the payload
a nullable decltype(Min) — the same "one optional value" shape Choice
uses, so the type participates in allRequiredEngaged the same way.
Multiline has no distinguishable empty state: its payload is a plain
std::string, and (like an unwrapped std::string member) the forms module
cannot tell "not filled in" apart from an intentionally blank string. It does
not define hasValue() and therefore does not satisfy EmptyCapableField
(forms.md, "Empty state") — it is always considered engaged.
Ranged's payload is std::optional<decltype(Min)>; a default-constructed
Ranged is empty. hasValue() reports engagement, operator* gives
unchecked access (UB when empty, exactly like Choice/std::optional).
Both types serialise through glz::meta as their bare payload:
template <>
struct glz::meta<morph::forms::Multiline> {
static constexpr auto value = &morph::forms::Multiline::value;
static constexpr std::string_view name = "Multiline";
};
template <auto Min, auto Max, auto Step>
struct glz::meta<morph::forms::Ranged<Min, Max, Step>> {
static constexpr auto value = &morph::forms::Ranged<Min, Max, Step>::value;
static constexpr std::string_view name = morph::forms::detail::rangedSchemaName<decltype(Min)>;
};(Ranged's name varies with its payload type rather than being one fixed
string — see Schema representation for why.)
Multiline therefore serialises as a plain JSON string; Ranged as
decltype(Min) | null. Neither the widget preference nor (for Ranged) the
slider bounds ever travel with a payload — they live only in the C++ type and
the generated schema, exactly like Choice's options metadata
(choice.md).
In the generated schema (morph::forms::schemaJson) a Multiline property
gets x-widget: "textarea"; a Ranged property gets x-widget: "slider"
plus x-min / x-max / x-step. Neither type is std::optional<...>
itself, so both are required by forms.md's Required-ness rule
(forms.md) unless the action opts the field
out via optionalFields.
Multiline has no template parameters, so its glz::meta name is the fixed
"Multiline". Ranged<Min, Max, Step> composes its name from the payload
type:
Ranged_<tag> tag = "bool", or c|f|i|u followed by the width in bits
so Ranged<0, 100, 5> keys $defs/Ranged_i32 and Ranged<0.5, 2.5, 0.5> keys
$defs/Ranged_f64. The tag is derived from sizeof and the standard type
traits — never from a compiler's own spelling of the type, for the reason
choice.md gives. c marks the character
types (char and friends), which glaze writes as a JSON string: char and
std::int8_t are both signed and both 8 bits wide, so without that distinction
they would share an entry that describes only one of them.
The payload type is the whole of what the entry describes, which is why it
is the whole of what the name carries. A Ranged definition is the schema of
std::optional<decltype(Min)>; the field's own bounds are emitted as
property-level x-min / x-max / x-step and never reach the $def. So
two differently-bounded int sliders share one entry — their entries are
identical, and that is what $defs is for — while an int slider and a
double slider get one each.
They have to. glaze populates a $defs entry only once, so if every
instantiation shared the single name "Ranged", the second one would be skipped
and $ref the first one's definition: a Ranged<0.0, 1.0, 0.1> next to a
Ranged<0, 100> would be served as {"type":["integer","null"], "minimum": -2147483648, …} while its property correctly carried "x-step": 0.1 — every
legal value of the double slider failing the type it was handed under. This is
the same defect choice.md describes for
Choice.
Because these keys are part of the emitted document, changing this composition
is a wire-shape change for any client that resolves $ref targets by name.
mergeSchemaExtras (verified in forms.hpp) computes each property's
x-widget in two steps, for every reflected member (not only Multiline
/ Ranged fields):
- Type-derived default. If the member's type exposes a
noexcept static constexpr widget()returning something convertible tostd::string_view(the shapeMultilineandRangedboth have —detail::DeclaresWidgetinforms.hpp), that string is the field's default widget hint. - Override. If the action declares a
static constexpriterablefieldMetadata(mirroring theoptionalFieldsconvention) whose element type exposes.fieldand.widget, both convertible tostd::string_view(detail::HasFieldMetadataWidgets<A>informs.hpp), and one entry's.fieldequals the member's wire name with a non-empty.widget, that string replaces the type-derived default.
Both checks are structural (duck-typed): forms.hpp never names or
includes a FieldMeta type in this lookup. In practice, every action that
declares fieldMetadata today uses morph::forms::FieldMeta
(forms.md, "Field metadata") — which
already carries .field and .widget — so the override mechanism is
exercised through that one concrete type; the structural check simply means
forms.hpp would honour any other descriptor array shaped the same way, with
no header dependency of its own on FieldMeta's declaration.
x-min / x-max / x-step are not overridable through fieldMetadata —
they come solely from a Ranged field's own min() / max() / step() (via
detail::DeclaresRangedBounds<Member>), independent of whatever x-widget
ends up on the property. This is deliberate: overriding which control
renders (x-widget) is orthogonal to the numeric bounds a slider (if
rendered) would use — a fieldMetadata override that turns a Ranged field
into e.g. "combo" still leaves x-min/x-max/x-step on the property,
harmless for a renderer that ignores them.
| Member | Signature | Notes |
|---|---|---|
value |
std::string value |
Public data member; the payload. |
| default ctor | constexpr Multiline() noexcept |
Empty string. |
| value ctor | Multiline(std::string text) noexcept(...) |
Implicit; engages, moving from text. |
widget() |
static constexpr std::string_view widget() noexcept |
Always "textarea". |
operator== |
constexpr bool operator==(const Multiline&) const |
Defaulted; compares value. |
| Member | Signature | Notes |
|---|---|---|
value |
std::optional<decltype(Min)> value |
Public data member; the payload. |
| default ctor | constexpr Ranged() noexcept |
Empty state. |
| value ctor | constexpr Ranged(decltype(Min) selected) noexcept |
Implicit; engages. |
| optional ctor | constexpr Ranged(std::optional<decltype(Min)> payload) noexcept |
Implicit; adopts as-is. |
hasValue() |
constexpr bool hasValue() const noexcept |
Engaged? |
operator* |
constexpr decltype(Min) operator*() const noexcept |
Unchecked (UB when empty). |
min() |
static constexpr auto min() noexcept |
Returns Min. |
max() |
static constexpr auto max() noexcept |
Returns Max. |
step() |
static constexpr auto step() noexcept |
Returns Step. |
widget() |
static constexpr std::string_view widget() noexcept |
Always "slider". |
operator== |
constexpr bool operator==(const Ranged&) const |
Defaulted; empty equals only empty. |
| Symbol | Kind | Notes |
|---|---|---|
DeclaresWidget<T> |
concept | true when T exposes a noexcept static constexpr widget(). |
DeclaresRangedBounds<T> |
concept | true when T exposes noexcept min()/max()/step(). |
HasFieldMetadataEntries<A> |
concept | true when A::fieldMetadata is iterable. |
HasFieldMetadataWidgets<A> |
concept | true when A::fieldMetadata's entries expose .field/.widget. |
widgetOverride<A>(name) |
constexpr function | The matching entry's .widget, or "" when none matches or A has no fieldMetadata. |
| Decision | Choice | Why |
|---|---|---|
| Wire representation | Bare payload, no wrapper metadata | Same rule as Choice/Quantity: rendering intent lives in the type and the schema, never the payload. |
| Empty state | Multiline: none; Ranged: std::optional |
Multiline wraps a type (std::string) with no framework-recognised empty state of its own; Ranged wraps a scalar, which needs an explicit optional to have one. |
| Widget default | Type-derived, via a widget() static function |
Matches the "infer by default, declare to override" design principle; any user type can opt in the same way Multiline/Ranged do, with no registration step. |
| Widget override | Duck-typed on fieldMetadata's .field/.widget |
Lets the override live in a descriptor whose canonical definition is owned by a different feature (forms.md, "Field metadata") without forms.hpp gaining a named dependency on that type. |
| Range subfields | Not overridable via fieldMetadata |
x-min/x-max/x-step describe the type's declared bounds; overriding the widget choice must not silently change what those bounds mean. |
$defs naming |
Fixed name per wrapper ("Multiline", "Ranged") |
Same trade-off as Choice: instantiations collapse into one shared $def, harmless because the differentiating data (bounds, widget) is property-level. |
#include <morph/forms/widget_hints.hpp>
#include <morph/forms/forms.hpp>
struct SubmitFeedback {
morph::forms::Multiline comments;
morph::forms::Ranged<1, 5, 1> rating;
std::string category;
// "category" gets a widget purely from the override; "rating" keeps its
// Ranged-derived "slider" (no entry names it here).
static constexpr std::array fieldMetadata{
morph::forms::FieldMeta{.field = "category", .widget = "radio"},
};
[[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); }
};The generated schema surfaces comments with x-widget: "textarea",
rating with x-widget: "slider" plus x-min: 1, x-max: 5, x-step: 1,
and category with x-widget: "radio" (a plain std::string, annotated
purely through the override). All three are listed in required (none is
std::optional, none is in optionalFields); allRequiredEngaged gates on
rating.hasValue() but not on comments or category (neither is
empty-capable).
widgetOverride<A> only ever compares .field against the wire names
forEachNamedMember actually visits; an entry naming a field that does not
exist on A (a typo, a renamed member) never matches anything and is
dropped — consistent with optionalFields's existing behaviour
(forms.md) and with schema generation never throwing.
HasFieldMetadataWidgets<A> requires every element of A::fieldMetadata
to expose .field and .widget convertible to std::string_view. If an
action's descriptor array element type is missing either member, the concept
is simply not satisfied and no override applies for any field on that
action — not a partial application. Widget hints then fall back entirely to
each field's own type-derived widget() (or none). This is independent of
(and additionally constrained versus) detail::HasFieldMetadata<A> — the
label/help/placeholder lookup — which requires A::fieldMetadata's elements
to be convertible to morph::forms::FieldMeta specifically, so that
detail::findFieldMeta can hand back a typed pointer; a fieldMetadata array
of some other shape satisfies the widget-override concept without satisfying
that one, and vice versa is not possible since FieldMeta itself satisfies
both.
- No option-count-driven radio/combo. Whether a
Choicerenders as radio buttons is always an explicitfieldMetadataoverride — never inferred from how many options the options action happens to return, which is not known at schema-generation time (choice.md). x-widgetis advisory, never validation. A slider'sx-min/x-maxis a control track only; value-bounds enforcement stays with glaze's ownminimum/maximum(when declared) and server-side checks — a renderer may legitimately present a wider or narrower track than any validation bound.- The control-id vocabulary is not enumerated here.
x-widgetcarries whatever string the type or override supplies; this spec does not enumerate every id a renderer must recognise — only"textarea"(fromMultiline) and"slider"(fromRanged) are type-derived, and any other id ("radio","combo","password", …) is meaningful only by convention between the action's author and the target renderer.
- forms.md —
schemaJson<A>(),mergeSchemaExtras,forEachNamedMember,EmptyCapableField/requiredderivation these wrappers plug into;FieldMetaand its.widgetmember (forms.md, "Field metadata"), the concrete descriptor type most actions use to supply the override this spec describes; and the renderer-contract tablex-widget/x-min/x-max/x-stepextend. - choice.md — the type-carries-intent / wire-carries-value
pattern and the
$defs-collapse consequence both wrappers follow. - quantity_type.md —
Quantity's ownx-decimalPlacesentry-granularity annotation, the analogous (but distinct) numeric-precision contractx-stepdoes not replace. - forms.md —
the infer-by-default / declare-to-override principle and the additive-
x-*versioning stance this feature obeys.