Skip to content

Adding a Component

Components are the building blocks inside a modality. A TextBoxComponent renders text, a CardComponent renders a styled card, a ShapeComponent renders geometric shapes. Each component declares its property schema via a typed struct and compiles itself into elements.

This guide walks through creating a new BadgeComponent for the whiteboard — a simple label with a background color.

Every leaf component implements the TypedComponent trait, which provides typed properties and static identity metadata:

trait TypedComponent: Send + Sync + 'static {
type Properties: ComponentProperties;
type Metadata: Hash;
type Output: Clone;
fn id() -> &'static str;
fn name() -> &'static str;
fn description() -> &'static str;
fn icon() -> &'static str;
fn compile(
&self,
props: &Self::Properties,
metadata: &Self::Metadata,
) -> Result<Self::Output, CompileError>;
}

A blanket impl bridges this to the base Component trait automatically — you never implement Component directly for leaf components. Similarly, modality-specific subtraits (WhiteboardComponent, WorksheetComponent) are blanket-implemented for any TypedComponent with matching associated types.

Use #[derive(ComponentProperties)] to generate schema, extraction, and binding methods:

use modality_core::ComponentProperties;
use crate::elements::Color;
#[derive(Clone, Copy, Default, modality_core::PropertyField)]
pub enum BadgeColor {
#[default]
Blue,
Green,
Red,
Yellow,
}
#[derive(ComponentProperties)]
pub struct BadgeProps {
#[property(name = "Label", required, description = "Badge text")]
pub label: String,
#[property(name = "Color", description = "Background color")]
pub color: BadgeColor,
#[property(key = "fontSize", name = "Font Size", min = 8, max = 72, default = "16")]
pub font_size: f64,
}

The #[property(...)] attribute supports:

  • key — override the property key string (defaults to field name). Use for camelCase backward compatibility.
  • name — human-readable display name
  • default — default value as a string literal
  • min, max — numeric constraints
  • max_length — text length constraint
  • description — shown in editors and AI prompts
  • icon — icon identifier
  • required, system, title — boolean flags

For enum fields, derive PropertyField to generate PropertyType::Select with lowerCamelCase option strings.

The macro generates:

  • BadgeProps::schema() -> Vec<PropertySchema> — property schemas
  • BadgeProps::from_values(&HashMap<String, PropertyValue>) -> Self — typed extraction with defaults
  • BadgeProps::to_bindings(&self) -> HashMap<String, PropertyBinding> — for element creation in reducers
  • BadgePropsUpdate — an all-Option struct with apply(self, &mut bindings) for partial updates
use modality_core::TypedComponent;
pub struct BadgeComponent;
impl TypedComponent for BadgeComponent {
type Properties = BadgeProps;
type Metadata = WhiteboardChildMeta;
type Output = Vec<WhiteboardElement>;
fn id() -> &'static str { "badge" }
fn name() -> &'static str { "Badge" }
fn description() -> &'static str { "Simple label with a colored background" }
fn icon() -> &'static str { "label" }
fn compile(
&self,
props: &BadgeProps,
meta: &WhiteboardChildMeta,
) -> Result<Vec<WhiteboardElement>, CompileError> {
let bounds = &meta.bounds;
let background = WhiteboardElement::Shape(ShapeElement {
id: next_id(),
position: Offset { dx: bounds.x, dy: bounds.y },
size: Size { width: bounds.width, height: bounds.height },
color: badge_color_to_color(props.color),
fill: ShapeFill::Solid,
radius: 8.0,
..Default::default()
});
let blocks = prose::render_prose_bytes(
&prose::create_from_plain_text(&props.label),
);
let text = WhiteboardElement::Text(TextElement {
id: next_id(),
position: Offset {
dx: bounds.x + 12.0,
dy: bounds.y + (bounds.height / 2.0) - (props.font_size / 2.0),
},
size: Size {
width: bounds.width - 24.0,
height: props.font_size * 1.5,
},
blocks,
properties: TextBoxProperties::default(),
});
Ok(vec![background, text])
}
}
fn badge_color_to_color(c: BadgeColor) -> Color {
match c {
BadgeColor::Blue => Color(0xFF3B82F6),
BadgeColor::Green => Color(0xFF22C55E),
BadgeColor::Red => Color(0xFFEF4444),
BadgeColor::Yellow => Color(0xFFEAB308),
}
}

Note: compile() receives a typed &BadgeProps struct, not a HashMap. The blanket Component impl handles extraction automatically.

Step 3: Register in the Component Registry

Section titled “Step 3: Register in the Component Registry”

Components use the inventory crate for static registration. Add a single line:

inventory::submit!(WhiteboardComponentEntry(&BadgeComponent));

That’s it. The component is automatically discovered by resolve_component() and all_component_schemas() at runtime — no manual match arm needed.

Create a placement that references your new component. In a reducer intent handler, use to_bindings():

let placement = ComponentPlacement {
id: "badge-1".to_string(),
component_key: "badge".to_string(),
component_id: None,
position: WhiteboardPosition { x: 100.0, y: 200.0, width: 150.0, height: 40.0, .. },
bindings: BadgeProps {
label: "New".to_string(),
color: BadgeColor::Green,
font_size: 14.0,
}.to_bindings(),
};

For partial updates in intent handlers, use the generated Update struct:

BadgePropsUpdate {
color: Some(BadgeColor::Red),
font_size: Some(18.0),
..Default::default()
}.apply(&mut placement.bindings);

When the modality compiles, it will:

  1. Call child_metadata(&placement) to get the WhiteboardChildMeta from the position.
  2. Resolve bindings to HashMap<String, PropertyValue>.
  3. Call resolve_component("badge") to find BadgeComponent via inventory.
  4. The blanket Component impl calls BadgeProps::from_values(&values) to extract typed props.
  5. Call badge.compile(&props, &meta) to produce Vec<WhiteboardElement>.

Components are easy to test in isolation since compile() is a pure function:

#[test]
fn test_badge_compile() {
let meta = WhiteboardChildMeta {
id: "b1".into(),
bounds: Rect::new(0.0, 0.0, 150.0, 40.0),
};
let values = HashMap::from([
("label".into(), PropertyValue::String("Test".into())),
("color".into(), PropertyValue::String("red".into())),
]);
let result = Component::compile(&BadgeComponent, &meta, &values);
assert!(result.is_ok());
let elements = result.unwrap();
assert_eq!(elements.len(), 2); // background + text
}
#[test]
fn test_badge_missing_label_uses_default() {
let meta = WhiteboardChildMeta {
id: "b2".into(),
bounds: Rect::new(0.0, 0.0, 150.0, 40.0),
};
// from_values always succeeds with defaults -- no MissingRequired panic
let result = Component::compile(&BadgeComponent, &meta, &HashMap::new());
assert!(result.is_ok());
}