Extending Primitives
Most UI is composition of Box + Text + Input. Only add new primitives when truly needed.
When to Extend
Add a primitive when you need:
- Custom drawing (Canvas for arbitrary graphics)
- Special input handling (Slider, Knob)
- Performance optimization (virtualized list)
If it can be composed from existing primitives, make it a component instead.
How to Extend
1. Add to PrimitiveType enum (VNode.hpp)
enum class PrimitiveType { Box, Text, Input, Scroll, Canvas, Portal };
2. Define props struct (Props.hpp)
Props structs inherit from LayoutProps and EventProps:
struct CanvasProps : LayoutProps, EventProps {
std::function<void(void*, float w, float h)> draw;
// Add state-based style overrides if needed
std::optional<CanvasStyle> hoverStyle;
std::optional<CanvasStyle> focusStyle;
};
// Add to PropsVariant
using PropsVariant = std::variant<BoxProps, TextProps, InputProps, ScrollProps, CanvasProps, PortalProps>;
3. Create VNode factory (VNode.hpp)
inline VNode Canvas(CanvasDrawFn drawFn) {
VNode n;
CanvasProps p;
p.draw = std::move(drawFn);
n.props = std::move(p); // VNode::type() derives from props.index()
return n;
}
The primitive’s position in PropsVariant is its PrimitiveType (a static_assert
in VNode.hpp keeps the enum and the variant in the same order), so setting props
is all that’s needed — n.type() reports Canvas automatically.
4. Implement Node subclass (Node.hpp/cpp)
class CanvasNode : public Node {
public:
CanvasNode();
PrimitiveType type() const override { return PrimitiveType::Canvas; }
void updateProps(const PropsVariant& props) override;
CanvasProps props;
};
In updateProps, extract props and call applyLayoutProps():
void CanvasNode::updateProps(const PropsVariant& p) {
props = std::get<CanvasProps>(p);
applyLayoutProps(props); // Apply layout to Yoga node
}
New primitives automatically participate in the dual-tree architecture. The Reconciler creates a Host fiber and corresponding render Node for each instance. No Fiber-specific code is needed — fibers only require special handling for Components.
5. Update createNode factory (Node.cpp)
std::unique_ptr<Node> createNode(PrimitiveType type) {
switch (type) {
// ...existing cases...
case PrimitiveType::Canvas: return std::make_unique<CanvasNode>();
}
}
6. Add to the render walk (src/render/TreeRenderer.cpp)
Since 1.1, drawing is not per-backend — a new primitive’s paint logic goes
into the one backend-neutral walk, render::renderTree, which every backend
shares (see Architecture — Rendering). Add a
case to the node-type switch there, expressed only in terms of the
IRenderBackend primitives (fillRect, strokeRect, pushClip/popClip,
drawTextRun, drawCanvas):
// in the renderTree node-type switch:
case PrimitiveType::MyPrimitive: {
auto* n = static_cast<MyPrimitiveNode*>(node);
backend.fillRect(absoluteRectOf(n), n->props.backgroundColor.value_or(0), 0);
// ...
break;
}
Do not add a per-backend draw method (e.g. NvgRenderer::drawMyPrimitive)
unless the primitive needs something no IRenderBackend primitive can
express — that would reintroduce exactly the per-backend duplication the
render seam was built to eliminate. Canvas is the one primitive that
legitimately needs backend-specific handling (the user’s draw callback
receives an opaque, backend-specific context), which is why
IRenderBackend::drawCanvas exists as a dedicated virtual — most new
primitives should need nothing beyond the shared fill/stroke/clip/text
surface.