Enum & Variant Support — Roadmap
Motivation
ParameterSchema and AutoParamWidget currently support five field types: float, double, int, bool, and std::string. Enum-like selection is achieved by manually populating allowed_values in a ParameterUIHints specialization on a std::string field. This has three problems:
- No compile-time safety. Typos in string values are not caught until runtime. Nothing prevents a generator from comparing
model == "linier"and silently falling through. - Manual duplication. The enum values must be listed once in the code that checks the string, and again in the
ParameterUIHints. They can drift apart. - No variant/conditional UI. When a parameter struct has mutually exclusive groups of fields (e.g., linear-motion params vs sinusoidal vs Brownian), all fields are displayed simultaneously with optional checkboxes. The user sees 20+ fields with no indication that most are irrelevant for the chosen mode.
This roadmap adds two capabilities to the ParameterSchema / AutoParamWidget stack:
- Phase 1 — Native
enum classsupport: Auto-detect C++ scoped enum fields, extract their enumerator names at compile time, and render them as combo boxes with zero manual annotation. - Phase 2 — Tagged variant support: Represent mutually-exclusive parameter groups as a discriminated union. The UI dynamically swaps the displayed sub-form when the discriminator changes.
Current Architecture
Params struct (reflect-cpp)
│
▼
extractParameterSchema<T>() ← rfl::fields<T>() + type-string parsing
│
▼
ParameterSchema { vector<ParameterFieldDescriptor> }
│
▼
AutoParamWidget::setSchema() ← builds form rows from descriptors
extractParameterSchema is a header-only template that calls rfl::fields<Params>() to get a compile-time list of rfl::MetaField objects. Each MetaField provides .name() (string) and .type() (string). The template then calls parseUnderlyingType() (a non-template function in the .cpp) to classify the type string into one of the five known base types.
Key constraint: rfl::fields() gives us field types as strings, not as actual C++ types. However, extractParameterSchema is a template instantiated with Params, so we have full compile-time access to the actual struct type. We can use rfl::to_view(Params{}) or structured bindings to get the actual field types and use std::is_enum_v<FieldType> and rfl::get_enumerator_array<FieldType>().
Phase 1 — Native enum class Support
Goal
A parameter struct can use a C++ enum class (or std::optional<enum class>) for any field. extractParameterSchema auto-detects it, populates allowed_values with the enumerator names, sets type_name = "enum", and AutoParamWidget renders a QComboBox. No ParameterUIHints needed.
Design
1a. Compile-Time Enum Detection in extractParameterSchema
The template already has full compile-time knowledge of Params. We enhance the per-field loop to detect enums:
template<typename Params>
ParameterSchema extractParameterSchema() {
// ... existing code ...
// For each field, we use rfl::to_view to get the actual typed reference.
// rfl::to_view returns a named tuple of references to each field.
auto const defaults = Params{};
auto const view = rfl::to_view(defaults);
int order = 0;
rfl::apply([&](auto const &... field_refs) {
auto meta_it = meta_fields.begin();
((processField<Params>(desc, *meta_it++, field_refs, order++, defaults_obj, schema)), ...);
}, view);
ParameterUIHints<Params>::annotate(schema);
return schema;
}A helper processField template function receives the actual typed reference, enabling:
template<typename FieldRef>
void processField(...) {
using FieldType = std::remove_cvref_t<decltype(field_ref.value())>;
using InnerType = unwrap_optional_t<FieldType>; // strips std::optional
if constexpr (std::is_enum_v<InnerType>) {
desc.type_name = "enum";
auto const enumerators = rfl::get_enumerator_array<InnerType>();
for (auto const & [name, value] : enumerators) {
desc.allowed_values.emplace_back(std::string(name));
}
}
}rfl::get_enumerator_array<E>() returns a std::array<std::pair<StringView, E>, N> containing all enumerator names and values. This is a compile-time operation provided by reflect-cpp for scoped enums with values in [0, 127].
1b. ParameterFieldDescriptor Changes
struct ParameterFieldDescriptor {
// ... existing fields ...
std::string type_name; // Now also: "enum"
// allowed_values: already exists, populated automatically for enums
};No new fields needed. The existing allowed_values vector is populated automatically. type_name gets a new value "enum" to distinguish from string-with-allowed-values (though AutoParamWidget treats both identically).
1c. AutoParamWidget Changes
Minimal. The existing branch for std::string with allowed_values already creates a QComboBox. Add a parallel branch for type_name == "enum":
} else if (desc.type_name == "enum" && !desc.allowed_values.empty()) {
// Same QComboBox logic as std::string with allowed_values
auto * combo = new QComboBox(this);
for (auto const & val : desc.allowed_values) {
combo->addItem(QString::fromStdString(val));
}
// ... default value, signals ...
}toJson() emits the enum value as a JSON string (matching reflect-cpp’s serialization). fromJson() sets the combo box by text.
1d. parseUnderlyingType Enhancement
For the non-template string-based path (used when a params struct contains enum types), the type string will contain the enum’s qualified name (e.g., "MotionModel" or "std::optional<MotionModel>"). Since the template-based path handles enum detection directly, parseUnderlyingType does not need to identify enums — it can continue to return the raw type string for unknown types. The template path overrides type_name before it is used.
1e. Default Value Handling
reflect-cpp serializes enums as their string name. The existing JSON-based default extraction (rfl::json::write(Params{}) → parse) already produces "linear" for an enum field defaulting to MotionModel::linear. No changes needed for defaults.
Deliverables
| Item | File | Description |
|---|---|---|
| Compile-time enum detection | ParameterSchema.hpp |
Template detects std::is_enum_v via rfl::to_view, calls rfl::get_enumerator_array |
type_name = "enum" |
ParameterSchema.hpp |
Set for detected enum fields |
allowed_values auto-population |
ParameterSchema.hpp |
From rfl::get_enumerator_array |
QComboBox for "enum" type |
AutoParamWidget.cpp |
Same pattern as string+allowed_values |
toJson / fromJson for enums |
AutoParamWidget.cpp |
Emit/parse as JSON strings |
| Unit tests | test_parameter_schema.test.cpp |
Struct with enum field → schema has type_name="enum", allowed_values correct |
Testing
- Define a test
enum class TestMode { fast, balanced, accurate };and a params struct with aTestModefield and anstd::optional<TestMode>field. extractParameterSchema<TestParams>()→ verifytype_name == "enum",allowed_values == {"fast", "balanced", "accurate"}.- Default-constructed struct →
default_value_json == "\"fast\"". - Round-trip:
rfl::json::write(params)→rfl::json::read<TestParams>(json)preserves enum values.
Exit Criteria
- No
ParameterUIHintsspecialization needed for enum fields. AutoParamWidgetrenders enum fields as combo boxes automatically.- Existing string+allowed_values pattern still works unchanged.
- All existing tests pass.
Phase 2 — Tagged Variant Support ✅ (Completed)
Goal
A parameter struct can contain a discriminated union field that represents mutually exclusive parameter groups. extractParameterSchema produces a nested schema structure. AutoParamWidget renders the discriminator as a combo box and dynamically swaps the visible sub-form when the selection changes.
Design
This phase has two sub-parts: the schema representation (ParameterSchema) and the UI rendering (AutoParamWidget).
2a. Schema Representation
Variant Field Descriptor
A new struct represents a variant (discriminated union) field:
struct VariantAlternative {
std::string tag; ///< Discriminator value (e.g., "linear")
ParameterSchema schema; ///< Sub-schema for this alternative's fields
};
struct ParameterFieldDescriptor {
// ... existing fields ...
// Variant support (populated only for variant fields)
bool is_variant = false;
std::string variant_discriminator; ///< Field name used as tag (e.g., "model")
std::vector<VariantAlternative> variant_alternatives;
};When is_variant == true:
type_nameis"variant"variant_discriminatornames the JSON tag fieldvariant_alternativescontains one entry per union alternative, each with its ownParameterSchemadescribing the fields for that alternativeallowed_valuesis auto-populated with the tag names (for combo box)
Two Integration Patterns
There are two ways users can express variants in their params structs. ParameterSchema should support both:
Pattern A — rfl::TaggedUnion (reflect-cpp native)
struct LinearMotionParams {
float velocity_x = 1.0f;
float velocity_y = 0.0f;
};
struct SinusoidalMotionParams {
float amplitude_x = 0.0f;
float amplitude_y = 0.0f;
float frequency_x = 0.0f;
float frequency_y = 0.0f;
float phase_x = 0.0f;
float phase_y = 0.0f;
};
struct BrownianMotionParams {
float diffusion = 1.0f;
uint64_t seed = 42;
};
using MotionVariant = rfl::TaggedUnion<
"model",
LinearMotionParams,
SinusoidalMotionParams,
BrownianMotionParams>;
struct MovingPointParams {
float start_x = 100.0f;
float start_y = 100.0f;
int num_frames = 100;
MotionVariant motion; // ← variant field
// ... boundary params ...
};JSON:
{
"start_x": 100,
"num_frames": 200,
"motion": {
"model": "LinearMotionParams",
"velocity_x": 2.0,
"velocity_y": 0.5
}
}Pattern B — std::variant + discriminator enum (no rfl::TaggedUnion)
enum class MotionModel { linear, sinusoidal, brownian };
struct MovingPointParams {
float start_x = 100.0f;
int num_frames = 100;
MotionModel model = MotionModel::linear; // discriminator (Phase 1 enum)
std::variant<LinearMotionParams, SinusoidalMotionParams, BrownianMotionParams> motion_params;
};This requires manual serialization logic but integrates naturally with Phase 1 enum support.
Recommendation: Support Pattern A (rfl::TaggedUnion) first because reflect-cpp handles serialization automatically. Pattern B can be added later if needed; it’s more work because std::variant doesn’t self-describe its discriminator.
Compile-Time Detection
In extractParameterSchema, detect rfl::TaggedUnion fields:
if constexpr (is_tagged_union_v<InnerType>) {
desc.is_variant = true;
desc.type_name = "variant";
desc.variant_discriminator = InnerType::discriminator_; // compile-time string
// For each alternative type in the union:
rfl::apply_to_tagged_union<InnerType>([&](auto type_tag) {
using AltType = typename decltype(type_tag)::type;
VariantAlternative alt;
alt.tag = typeid(AltType).name(); // or rfl-provided name
alt.schema = extractParameterSchema<AltType>();
desc.variant_alternatives.push_back(std::move(alt));
});
// Populate allowed_values for the combo box
for (auto const & alt : desc.variant_alternatives) {
desc.allowed_values.push_back(alt.tag);
}
}The exact compile-time introspection API for rfl::TaggedUnion needs verification — reflect-cpp exposes the alternative types as a type list. A is_tagged_union_v<T> trait will be needed (simple SFINAE or concept check on the discriminator_ member).
2b. AutoParamWidget — Dynamic Sub-Form Swapping
Widget Architecture
For a variant field, AutoParamWidget creates:
- A combo box populated from
allowed_values(one entry per alternative) - A stacked widget (
QStackedWidget) containing one sub-form per alternative - Each sub-form is built by recursively calling
buildFieldRowfor each field in the alternative’sParameterSchema
When the combo box selection changes, the stacked widget switches pages.
┌─────────────────────────────────┐
│ Motion Model: [Linear ▾] │ ← combo box (discriminator)
├─────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ Velocity X: [1.0 ] │ │ ← sub-form for "Linear"
│ │ Velocity Y: [0.0 ] │ │
│ └────────────────────────────┘ │
└─────────────────────────────────┘
─── user selects "Sinusoidal" ───
┌─────────────────────────────────┐
│ Motion Model: [Sinusoidal ▾] │
├─────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ Amplitude X: [0.0 ] │ │ ← sub-form for "Sinusoidal"
│ │ Amplitude Y: [0.0 ] │ │
│ │ Frequency X: [0.0 ] │ │
│ │ Frequency Y: [0.0 ] │ │
│ │ Phase X: [0.0 ] │ │
│ │ Phase Y: [0.0 ] │ │
│ └────────────────────────────┘ │
└─────────────────────────────────┘
FieldRow Extension
struct FieldRow {
// ... existing fields ...
// Variant support
bool is_variant = false;
QComboBox * variant_combo = nullptr;
QStackedWidget * variant_stack = nullptr;
std::vector<std::vector<FieldRow>> variant_sub_rows; // one vector per alternative
};toJson / fromJson for Variants
toJson(): When encountering a variant FieldRow, emit a JSON object with:
- The discriminator field (tag name from combo box text)
- All fields from the currently-selected sub-form
"motion": {
"model": "LinearMotionParams",
"velocity_x": 2.0,
"velocity_y": 0.5
}fromJson(): Read the discriminator value from the JSON object, switch the combo box to that alternative, then populate the sub-form fields.
Deliverables
| Item | File | Description |
|---|---|---|
VariantAlternative struct |
ParameterSchema.hpp |
Tag + sub-schema |
Variant fields in ParameterFieldDescriptor |
ParameterSchema.hpp |
is_variant, variant_discriminator, variant_alternatives |
is_tagged_union_v<T> trait |
ParameterSchema.hpp |
SFINAE detection |
| Compile-time variant extraction | ParameterSchema.hpp |
Template in extractParameterSchema |
QStackedWidget sub-form |
AutoParamWidget.cpp |
Dynamic page swapping |
Variant FieldRow members |
AutoParamWidget.hpp |
variant_combo, variant_stack, variant_sub_rows |
toJson / fromJson variant logic |
AutoParamWidget.cpp |
Nested JSON object with discriminator |
| Unit tests | test_parameter_schema.test.cpp |
TaggedUnion schema extraction, alternative enumeration |
| Widget test | test_auto_param_widget.test.cpp |
Combo box selection changes visible sub-form |
Testing
- Define three alternative structs (
LinearParams,SinParams,BrownianParams) and arfl::TaggedUnion<"model", ...>. extractParameterSchema<OuterParams>()→ verify variant field has three alternatives, each with correct sub-field schemas.- JSON round-trip: write params with each alternative → read back → values preserved.
- AutoParamWidget:
setSchema()creates combo + stacked widget →toJson()produces correct nested JSON →fromJson()restores correct alternative and values.
Exit Criteria
- Variant fields auto-detected from
rfl::TaggedUnion— no manual annotation. - AutoParamWidget swaps sub-forms on combo box change.
toJson/fromJsoncorrectly (de)serialize the discriminated structure.- Existing flat-struct schemas unaffected.
- All existing tests pass.
Implementation Notes
Phase 2 was implemented with the following design decisions:
VariantAlternative::schemausesstd::unique_ptr<ParameterSchema>to break the circular type dependency (VariantAlternative→ParameterSchema→ParameterFieldDescriptor→VariantAlternative).- Tag names are extracted at compile time by default-constructing each alternative inside a
TaggedUnion, serializing to JSON, and reading the discriminator field. This gives the reflect-cpp canonical name (the struct name). rfl::TaggedUnionhas no default constructor. Params structs must provide a default member initializer for variant fields (e.g.= LinearMotionParams{}).- Only Pattern A (
rfl::TaggedUnion) is implemented. Pattern B (std::variant+ enum discriminator) is deferred.
Phase 3 — Enhanced UI: Conditional Visibility (Optional)
Goal
For cases where a full rfl::TaggedUnion is too heavyweight, support conditional field visibility using ParameterUIHints annotations. This lets a flat parameter struct hide irrelevant fields based on a controlling enum or combo box field.
Design
Add two new annotation fields to ParameterFieldDescriptor:
struct ParameterFieldDescriptor {
// ... existing fields ...
/// If non-empty, this field is only visible when the named controller
/// field has one of the specified values.
std::string visibility_controller;
std::vector<std::string> visibility_values;
};Usage via ParameterUIHints:
template<>
struct ParameterUIHints<MyParams> {
static void annotate(ParameterSchema & schema) {
schema.field("velocity_x")->visibility_controller = "model";
schema.field("velocity_x")->visibility_values = {"linear"};
schema.field("diffusion")->visibility_controller = "model";
schema.field("diffusion")->visibility_values = {"brownian"};
}
};AutoParamWidget connects combo box currentIndexChanged signals to a slot that iterates all FieldRows and shows/hides rows based on visibility rules.
This is a simpler alternative to Phase 2 for cases where the flat-struct pattern is preferred (e.g., when the params struct is already established and refactoring to rfl::TaggedUnion would break existing JSON configs).
Exit Criteria
- Fields with
visibility_controllerannotations are hidden/shown correctly. toJson()excludes hidden optional fields (same as unchecked optional gate).- No interaction with Phase 1 (enum) or Phase 2 (variant) — orthogonal feature.
Implementation Order
| Phase | Scope | Dependency | Estimated Complexity |
|---|---|---|---|
| 1 | Enum class auto-detection | None | Low — mostly template additions |
| 2 | Tagged variant support | Phase 1 (enums useful as discriminators) | Medium — new schema type + UI widget |
| 3 | Conditional visibility | None (orthogonal) | Low — annotation + show/hide logic |
Recommended order: Phase 1 → Phase 2 → Phase 3.
Phase 1 is independently useful and unblocks Phase 2 (enums are natural discriminators). Phase 3 is orthogonal and can be done at any time, but is lower priority since Phase 2 provides a cleaner solution for the main use case.
Compiler Compatibility Notes
rfl::get_enumerator_array<E>()
reflect-cpp uses std::source_location + compiler function name parsing to extract enum names at compile time. Requirements:
- C++20 or later (this project uses C++23 ✓)
- Scoped enums only (
enum class, not unscopedenum) - Values in [0, 127] by default (configurable via
RFL_ENUM_RANGE) - GCC, Clang, MSVC all supported ✓
rfl::TaggedUnion
Available in reflect-cpp ≥ 0.10. Serializes as a JSON object with a discriminator field. All three target compilers support it.
Type String Portability
rfl::fields<T>() type strings differ between compilers. Phase 1 avoids this problem entirely by using rfl::to_view() + std::is_enum_v for compile-time detection rather than parsing the type string. Phase 2 similarly uses compile-time type traits (is_tagged_union_v) rather than string parsing.