Manifold
Manifold is a visual editor for immutable Dart models generated by artifact.
You describe your model once with @manifold + @Property, run codegen, and Manifold builds a full editor UI with nested object editing, collections, maps, raw text formats, and override hooks.
At A Glance
- Auto-generates field editors from artifact metadata.
- Emits a brand-new immutable model instance on every change.
onChangedreceives(value, valid)on every edit.- Supports:
- primitives:
String,int,double,bool,DateTime enum- nested artifact models
- recursive
List,Set, andMap - deep mixed nesting (
Map<String, List<Map<int, Set<Model>>>>)
- primitives:
- Supports read-only mode (
viewOnly). - Supports disabling root/sub-screen search (
enableSearch). - Supports disabling root/sub-screen raw editor actions (
enableRawEditor). - Supports:
- field-level overrides (
propertyEditorBuilder) - type-level overrides (
editorOverrides) - decorator replacement (
decoratorBuilder) - container strategy (
embedvssubScreen)
- field-level overrides (
- Built-in raw editors for YAML, JSON, TOML, TOON.
- Built-in live search for large model editors.
Install
dependencies:
manifold: any
artifact: any
dev_dependencies:
build_runner: any
artifact_gen: any
Then run:
dart run build_runner build --delete-conflicting-outputs
Model Example
The example below shows primitives, enums, nested artifacts, sets, maps, and deep recursive structures in one functional domain.
import 'package:manifold/manifold.dart';
enum DeploymentChannel { canary, stable, hotfix }
enum IncidentLevel { low, medium, high, critical }
@manifold
class ServiceEndpoint {
@Property(label: 'Base URL', hint: 'https://api.acme.dev')
final String baseUrl;
@Property(label: 'Timeout (seconds)', min: 0.5, max: 120)
final double timeoutSeconds;
@Property(label: 'Retries', min: 0, max: 10)
final int retries;
@Property(label: 'Enabled')
final bool enabled;
const ServiceEndpoint({
this.baseUrl = 'https://localhost',
this.timeoutSeconds = 10,
this.retries = 2,
this.enabled = true,
});
}
@manifold
class OnCallContact {
@Property()
final String name;
@Property()
final String? slack;
@Property()
final String? phone;
@Property()
final IncidentLevel level;
const OnCallContact({
required this.name,
this.slack,
this.phone,
this.level = IncidentLevel.low,
});
}
@manifold
class ReleasePlan {
@Property()
final String service;
@Property()
final DeploymentChannel channel;
@Property()
final DateTime deployAt;
@Property()
final ServiceEndpoint primary;
@Property()
final ServiceEndpoint? fallback;
@Property()
final List<ServiceEndpoint> regionalEndpoints;
@Property()
final Set<String> featureFlags;
@Property()
final Map<String, OnCallContact> onCallByRegion;
@Property()
final Map<String, ServiceEndpoint?> failoverByRegion;
@Property()
final Map<String, List<Map<int, Set<ServiceEndpoint>>>> rolloutTree;
const ReleasePlan({
required this.service,
required this.channel,
required this.deployAt,
required this.primary,
this.fallback,
this.regionalEndpoints = const [],
this.featureFlags = const {},
this.onCallByRegion = const {},
this.failoverByRegion = const {},
this.rolloutTree = const {},
});
}
Basic Usage
Import your generated artifacts file before opening ManifoldEditor<T> so accessors are registered.
import 'package:arcane/arcane.dart';
import 'package:my_app/gen/artifacts.gen.dart';
import 'package:my_app/models/release_plan.dart';
import 'package:manifold/manifold.dart';
class ReleasePlanEditorScreen extends StatefulWidget {
const ReleasePlanEditorScreen({super.key});
@override
State<ReleasePlanEditorScreen> createState() => _ReleasePlanEditorScreenState();
}
class _ReleasePlanEditorScreenState extends State<ReleasePlanEditorScreen> {
ReleasePlan value = ReleasePlan(
service: 'billing-api',
channel: DeploymentChannel.canary,
deployAt: DateTime.now().add(const Duration(hours: 2)),
primary: const ServiceEndpoint(baseUrl: 'https://billing.acme.dev'),
);
@override
Widget build(BuildContext context) {
return Screen(
child: ManifoldEditor<ReleasePlan>(
edit: value,
onChanged: (next, valid) {
// valid == true when all field validators currently pass.
setState(() => value = next);
},
),
);
}
}
Supported Field Types
| Type | Supported | Notes |
|---|---|---|
String, int, double, bool, DateTime |
Yes | Nullable and non-nullable |
enum |
Yes | Nullable and non-nullable |
| Artifact objects | Yes | Opens nested editor (embed or sub-screen) |
List<T> |
Yes | Reorderable |
Set<T> |
Yes | Not reorderable |
Map<K, V> |
Yes | Key/value entry editors, unique key checks |
| Recursive combinations | Yes | Descriptor-driven, no Type.toString parsing |
Collection And Map Behavior
List vs Set
List<T>supports add/remove/edit/reorder.Set<T>supports add/remove/edit (not reorder).
Map
- Add/remove/edit key/value pairs.
- Key uniqueness is enforced.
- Supports nested map/list/set/artifact values recursively.
Default values when adding
String->""int->0double->0.0bool->falseDateTime->DateTime.now()enum-> first inferred option- artifact ->
$AClass<T>.construct() - nullable target ->
null
For map keys:
- string keys auto-generate (
key1,key2, ...) - numeric/date keys increment to the next available value
- bool keys allow only
falseandtrue - if no safe unique key can be inferred, add is blocked with a toast message
Container Strategy: Embed Or Sub-Screen
Use containerStyle when you want fine control over whether a collection/map/object is inline (embed) or opened on navigation (subScreen).
class ReleaseContainerStyle extends ManifoldContainerStyle {
const ReleaseContainerStyle();
@override
ManifoldContainerType getCollectionStyle<M, T>({
required ManifoldEditorScope<M> scope,
required $AFld field,
required Iterable<T>? value,
}) {
if (field.name == 'regionalEndpoints') {
return ManifoldContainerType.subScreen;
}
if ((value?.length ?? 0) > 4) {
return ManifoldContainerType.subScreen;
}
return ManifoldContainerType.embed;
}
@override
ManifoldContainerType getMapStyle<M, K, V>({
required ManifoldEditorScope<M> scope,
required $AFld field,
required Map<K, V>? value,
}) {
if (field.name == 'rolloutTree') {
return ManifoldContainerType.subScreen;
}
return ManifoldContainerType.embed;
}
@override
ManifoldContainerType getSubObjectStyle<M, O>({
required ManifoldEditorScope<M> scope,
required $AFld field,
required O? value,
}) {
return ManifoldContainerType.subScreen;
}
}
Usage:
ManifoldEditor<ReleasePlan>(
containerStyle: const ReleaseContainerStyle(),
onChanged: (v, valid) {},
)
Read-Only Mode
Set viewOnly: true to disable all mutation UI. Inputs are disabled and provider pushes are ignored.
ManifoldEditor<ReleasePlan>(
viewOnly: true,
onChanged: (_, __) {},
)
Customization Hooks
1) propertyEditorBuilder (field-level override)
Runs before default logic. Return null to fall back to built-in behavior.
ManifoldEditor<ReleasePlan>(
propertyEditorBuilder: (ctx) {
if (ctx.field.name == 'service') {
return TextField(
readOnly: ctx.readOnly,
enabled: !ctx.readOnly,
placeholder: 'service-name',
onChanged: (v) => ctx.onChanged(v),
);
}
return null;
},
onChanged: (_, __) {},
)
2) editorOverrides (type-level override)
The user map is applied on top of default editors. Matching types replace defaults.
ManifoldEditor<ReleasePlan>(
editorOverrides: {
String: (ctx) => MStringField(
property: ctx.property,
readOnly: ctx.readOnly,
initialValue: (ctx.value as String?) ?? '',
onChanged: (v) => ctx.onChanged(v),
),
Duration: (ctx) => MIntField(
property: ctx.property,
readOnly: ctx.readOnly,
initialValue: (ctx.value as Duration?)?.inSeconds ?? 0,
onChanged: (seconds) => ctx.onChanged(Duration(seconds: seconds ?? 0)),
),
},
onChanged: (_, __) {},
)
ManifoldEditorOverrideContext includes:
field,propertyvalue,valueTypeonChangedcollectionElement(true for list/set/map item context)readOnly
3) Decorators
Use built-in decorators:
ManifoldDensePropertyDecoratorManifoldCompactPropertyDecorator
Or fully control field shell rendering with decoratorBuilder.
ManifoldEditor<ReleasePlan>(
decoratorBuilder: (ctx) {
return Card(
leading: Icon(ctx.icon),
titleText: ctx.label,
subtitleText: ctx.property?.description,
child: ctx.editor,
);
},
onChanged: (_, __) {},
)
The decorator context contains:
fieldpropertyvalueeditor(the actual field editor widget)readOnly
Raw Editors (YAML / JSON / TOML / TOON)
The root overflow menu includes text editors for supported formats.
Notes:
- Raw parse/import errors are surfaced as toasts.
- If a format cannot represent current values, it is disabled instead of crashing.
- Example: TOML cannot encode
nullvalues.
- Example: TOML cannot encode
- Raw edit/view actions can be disabled entirely with
enableRawEditor: false.
Search
Manifold includes live search in the root editor:
- filters visible fields by:
- field name / spaced label variant
- property label/description/hint
- value text
- nested artifact YAML preview where available
This keeps large models manageable without extra configuration.
Set enableSearch: false to hide live search controls.
Validation
- Validators can be defined via:
@Property(validators: [...])- standalone validator annotations on fields
onChanged(value, valid)always fires; usevalidto decide whether to persist upstream.- If a field has no validators, it is treated as valid.
Important Behavior Notes
- Only fields annotated with
@Propertyare shown. - Nested object editing can be forced inline with
inlineSubObjects: true. - Collection/map identity tracking is used to preserve focus/state during typing, search, and reorder operations.
- For fire_crud-style models,
documentPathis preserved across edits.
Troubleshooting
"No artifact accessor found for type ..."
Checklist:
- Verify model is annotated with
@manifold. - Re-run build runner.
- Import generated artifacts before building the editor.
- Ensure generated artifacts are current for your model signatures.
"Type mismatch" card in nested editor
This usually means runtime data shape does not match generated type descriptors (stale codegen or malformed raw import). Regenerate artifacts and re-check raw payloads.
TOML option disabled
Expected when current model contains values TOML cannot represent, especially null.
API Snapshot
ManifoldEditor<T> key params:
edit: optional initial instanceonChanged: requiredvoid Function(T value, bool valid)callbackviewOnly: read-only modeenableSearch: show/hide search controlsenableRawEditor: show/hide edit/view raw controlspropertyEditorBuilder: early field override hookeditorOverrides: type-based editor override mapdecorator: built-in decorator selectiondecoratorBuilder: full decorator overridecontainerStyle: embed/sub-screen strategy for collection/map/subobjectinlineSubObjects: force nested object embedding
Example App
See /example for a runnable demo with nested objects, enums, doubles, recursive collections, maps, and raw editor integration.
Libraries
- api
- creator
- decorator/compact_property_decorator
- decorator/dense_property_decorator
- decorator/dynamic_property_decorator
- editor
- field/bool
- field/date
- field/double
- field/enum
- field/int
- field/string
- gen/artifacts.gen
- gen/exports.gen
- manifold
- provider
- runtime
- util/code_editor
- util/m_collection
- util/m_section
- util/override_utils
- util/type_utils
- util/validators
- widget/artifact_field_chip
- widget/collection_editor
- widget/collection_item_editor
- widget/map_editor
- widget/map_entry_editor
- widget/property_editor
- widget/property_value_editor