Skip to content

Schema-Driven Toolbar

Replace the hardcoded ObjectToolbar (which switches on WhiteboardElement variants and builds bespoke widgets per type) with a generic, schema-driven toolbar. A Dart PropertyEditor widget takes a (PropertySchema, PropertyValue?) pair and returns a compact inline editor. The toolbar iterates the selected component’s schema in declaration order, rendering one PropertyEditor per property. This toolbar is modality-agnostic — it works for whiteboard, worksheet, document, or any modality whose components expose PropertySchema.

1. Every new element type requires new toolbar code

Section titled “1. Every new element type requires new toolbar code”

Today, ObjectToolbar has a switch on WhiteboardElement with dedicated _PathToolbar, _ShapeToolbar, and _TextToolbar widgets. Adding a new element type (e.g. FileComponent, UrlComponent) means writing another bespoke toolbar widget, even though the property types (Color, Select, Number) are the same across components. This doesn’t scale.

2. Components already declare their properties

Section titled “2. Components already declare their properties”

the Typed Element Components RFC established that each element type is a Component with properties() -> Vec<PropertySchema>. The schema declares every property’s type, name, constraints (min/max, select options), and order. The toolbar should read this schema rather than duplicate the knowledge.

Whiteboard, worksheet, and document modalities all use the Component trait. A worksheet question component has properties (difficulty, point value, tags) that need inline editing too. A single schema-driven toolbar widget can be reused everywhere — the modality only needs to provide (schema, values, on_change).

4. Property values already exist as PropertyValue

Section titled “4. Property values already exist as PropertyValue”

The compiled snapshot already contains resolved property values per element. The toolbar needs (Vec<PropertySchema>, HashMap<String, PropertyValue>) — both are already available from Rust via FRB.

A stateless Dart widget that maps PropertyType to a compact inline editor:

class PropertyEditor extends StatelessWidget {
final PropertySchema schema;
final PropertyValue? value;
final ValueChanged<PropertyValue> onChanged;
Widget build(BuildContext context) {
return switch (schema.propertyType) {
PropertyType_Color() => _ColorSwatch(value: value, onChanged: onChanged),
PropertyType_Number() => _NumberSlider(schema: schema, value: value, onChanged: onChanged),
PropertyType_Boolean() => _BoolToggle(value: value, onChanged: onChanged),
PropertyType_Select() => _SelectDropdown(schema: schema, value: value, onChanged: onChanged),
PropertyType_Text() => _TextChip(schema: schema, value: value, onChanged: onChanged),
_ => const SizedBox.shrink(), // unsupported types hidden
};
}
}

Each editor variant is a small, self-contained widget — _ColorSwatch opens a color picker popover, _NumberSlider renders a slider with the schema’s min/max, _SelectDropdown shows the schema’s options, etc. These replace the current bespoke widgets (_MiniColorPicker, ThicknessSlider, _FillCircle, _ArrowSelector).

class SchemaToolbar extends StatelessWidget {
final List<PropertySchema> schemas;
final Map<String, PropertyValue> values;
final ValueChanged<(String key, PropertyValue value)> onChanged;
Widget build(BuildContext context) {
final visible = schemas.where((s) => !s.isSystem && s.toolbar != ToolbarHint.hidden);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final (i, schema) in visible.indexed) ...[
if (i > 0) _divider(),
PropertyEditor(
schema: schema,
value: values[schema.key],
onChanged: (v) => onChanged((schema.key, v)),
),
],
],
);
}
}

The toolbar:

  1. Reads schemas in declaration order (from Component::properties())
  2. Filters out system properties and those marked hidden
  3. Renders a PropertyEditor for each, separated by dividers
  4. Calls back with (key, value) — the parent converts this to the appropriate intent

Add an optional toolbar field to PropertySchema:

pub struct PropertySchema {
// ... existing fields ...
/// How this property should appear in the toolbar.
/// Defaults to `Auto` (type-based widget).
#[serde(default)]
pub toolbar: ToolbarHint,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum ToolbarHint {
/// Default: render the standard widget for this property type.
#[default]
Auto,
/// Hide from the toolbar entirely. Useful for internal/computed properties.
Hidden,
}

Components that have properties unsuitable for the toolbar (e.g. PathComponent’s points property, which is a JSON blob of path data) set toolbar: ToolbarHint::Hidden in their schema declaration.

Dart-side override: custom editors and side-peeks

Section titled “Dart-side override: custom editors and side-peeks”

While SchemaToolbar provides the default rendering, the Dart layer can override behavior per component or per property type. The PropertyEditor accepts an optional builder override:

class SchemaToolbar extends StatelessWidget {
/// Optional per-key widget overrides. When provided for a key,
/// the custom widget is rendered instead of the default PropertyEditor.
final Map<String, WidgetBuilder>? overrides;
// ...
}

This enables:

  • Custom complex editors — e.g. the shape fill property could use the existing _FillCircle visual instead of a plain dropdown
  • Side-peek panels — Dart can intercept a property and open a panel/sheet instead of rendering inline, while still updating the same PropertyValue underneath
  • Grouped controls — e.g. start_arrow and end_arrow could be overridden as a single combined arrow editor widget

The key principle: Dart is in control of presentation. The schema provides the data contract; Dart chooses how to render it. A component that wants a non-standard UI provides a WidgetBuilder override — it still reads from and writes to PropertyValue.

The onChanged callback in SchemaToolbar produces (String key, PropertyValue value). The parent widget (e.g. ObjectToolbar in the whiteboard) translates this into the appropriate intent:

SchemaToolbar(
schemas: selectedComponent.schemas,
values: selectedComponent.values,
onChanged: (key, value) => dispatch(
WhiteboardIntent.updateProperty(
id: selectedId,
key: key,
value: value,
),
),
)

This requires a generic updateProperty intent on the Rust side that takes (id, key, value) and updates the corresponding binding on the placement. The existing typed intents (updateShapeProperties, updatePathProperties, etc.) can be kept as convenience wrappers or deprecated in favor of the generic one.

The whiteboard snapshot needs to include component schemas and current property values for the selected element. Two approaches:

Option A: Rust exposes schemas via a query API. A new FRB-exposed function returns the schema for a given component key:

pub fn component_schemas(component_key: &str) -> Vec<PropertySchema>

Values are already derivable from the element’s resolved bindings. Dart calls this when selection changes.

Option B: Snapshot includes schemas + values for selected element. The WhiteboardSnapshot carries an optional SelectedComponentInfo { schemas, values } populated when there’s a single selection. No extra API call needed.

Option A is simpler and avoids bloating snapshots.

SchemaToolbar and PropertyEditor live in a shared package (e.g. packages/modality/ or a new packages/property_editor/) — not in packages/whiteboard/. This makes them reusable across whiteboard, worksheet, document, and template editor contexts.

  1. Add ToolbarHint to PropertySchema — new toolbar field with Auto/Hidden. Default Auto. Regenerate FRB. Verify: cargo check && flutter analyze
  2. Add updateProperty intent — generic WhiteboardIntent::UpdateProperty { id, key, value } that updates a single binding on a placement. Verify: cargo test
  3. Add component_schemas() FRB API — looks up component by key, returns Vec<PropertySchema>. Verify: FRB codegen + flutter analyze
  4. Build PropertyEditor widget — Dart widget mapping PropertyType to inline editors (_ColorSwatch, _NumberSlider, _BoolToggle, _SelectDropdown). Start with Color, Number, Boolean, Select.
  5. Build SchemaToolbar widget — composes PropertyEditors in a row from schema list. Supports overrides map.
  6. Replace ObjectToolbar internals — use SchemaToolbar inside ObjectToolbar. Initially keep custom overrides for shape fill circles and arrow selectors for visual fidelity. Verify: visual comparison.
  7. Mark non-toolbar properties — set toolbar: Hidden on properties like points (path), rotation, and other internal-only properties. Verify: toolbar only shows relevant controls.
  8. Extend to other modalities — wire SchemaToolbar into worksheet and document component editing surfaces.

Per-component toolbar widgets registered alongside components

Section titled “Per-component toolbar widgets registered alongside components”

Each component registers not just its properties() but also a Dart toolbarBuilder. Rejected because it couples Rust component definitions to Dart UI code — the component registry is Rust-side and shouldn’t know about Flutter widgets. The schema-driven approach keeps the boundary clean: Rust declares data, Dart decides presentation.

Toolbar configuration enum per property type

Section titled “Toolbar configuration enum per property type”

Instead of a simple Auto/Hidden hint, have the schema specify exact widget types (ColorPicker, Slider, Dropdown, etc.). Rejected because the schema already carries PropertyType which determines the appropriate widget. Adding explicit widget hints creates a parallel type system. The overrides map on the Dart side handles any cases where the default mapping isn’t sufficient.

Build a property panel instead of an inline toolbar

Section titled “Build a property panel instead of an inline toolbar”

Replace the floating toolbar with a side panel showing all properties in a form layout (like Figma’s right panel). Rejected as the sole approach — a compact inline toolbar is better for quick edits on a canvas. However, this RFC’s SchemaToolbar can also render in a panel layout with a different parent widget. The PropertyEditor building blocks are the same either way.

Some properties only make sense given other values — e.g. stroke_width is irrelevant when fill is solid, radius only applies to rect shapes. Should the schema support conditional visibility rules (e.g. visible_when: "fill != solid")? Or should Dart handle this with its override mechanism? Leaning toward Dart-side logic initially, with schema-level conditions as a future enhancement if patterns emerge.

When multiple elements are selected, should the toolbar show the intersection of their schemas? Show shared properties with “mixed” state? This RFC focuses on single-element selection. Multi-select toolbar behavior can be a follow-up.