Extension points
HuxerUI does not require a base widget class or plugin registry. Extensions participate in the same composition, reconciliation, layout, interaction, paint, and platform boundaries as built-in UI. Start with ordinary composition and move outward only when the behavior has a different owner.
Start with composition
Section titled “Start with composition”An ordinary function returning View is the default extension:
View LabeledValue(std::string_view label, std::string_view value) { return Row { Text(label), Spacer(), Text(value), }.With(CrossAlign(CrossAxisAlignment::Center));}Add [[huxerui::composable]] only when the reusable component owns local state, a local event hub, or an independent recomposition boundary.
Keep application-owned values controlled: accept the current value and emit a typed event that asks the owner to update it.
Use an existing property modifier when reusable declarative data changes existing node behavior.
Use LayoutValue<Key> instead when the value is metadata interpreted specifically by a parent layout.
Neither mechanism needs a wrapper component or context object.
The pinned application API does not expose a stable custom property-modifier descriptor.
Application-defined retained behavior should use the automatic NodeExtension modifier pattern below; adding a new property modifier is a framework/library change because its descriptor applies directly to internal view properties.
Own a measurement policy with Layout
Section titled “Own a measurement policy with Layout”Derive from Layout<Derived> when the new abstraction is a reusable measurement and placement algorithm.
The policy measures children only through LayoutContext, obeys the incoming Constraints, and returns placements in local coordinates.
This complete wrapping layout uses the generic Spacing modifier rather than inventing a second gap API:
#include <huxerui/huxerui.h>
#include <algorithm>
using namespace huxerui;
class WrapLayout final : public Layout<WrapLayout> {public: using Layout::Layout;
static LayoutResult Measure(LayoutContext& context, ViewNode& node, Constraints constraints) { LayoutResult result; float x = 0.0F; float y = 0.0F; float line_height = 0.0F; float measured_width = 0.0F;
for (ViewNode& child : node.Children()) { const Size size = context.Measure(child, constraints.Loose()); if (x > 0.0F && x + size.width > constraints.max_width) { measured_width = std::max(measured_width, x - node.Spacing()); x = 0.0F; y += line_height + node.Spacing(); line_height = 0.0F; } result.Place(child, {x, y}); x += size.width + node.Spacing(); line_height = std::max(line_height, size.height); }
if (x > 0.0F) { measured_width = std::max(measured_width, x - node.Spacing()); } return result.SetSize(constraints.Constrain({measured_width, y + line_height})); }};
View App() { return WrapLayout { Button("Build"), Button("Test"), Button("Package"), }.With(Spacing(8.0F), Padding(24.0F));}
const Application application{App};The layout must not retain child references across reconciliation.
Runtime continues to own keyed identity, clipping, hit testing, scrolling, and mounted-node lifetime.
Prefer Row, Column, Stack, and Flow when their composition already expresses the policy.
Derive from VirtualLayout<Derived> only for a genuinely different visible-range algorithm over a large or unbounded data set.
Its policy decides what range is needed and where realized items are placed; Runtime owns realization and cleanup.
Try VirtualList or VirtualGrid first, and use stable semantic keys when data can reorder.
Retain node-local behavior with NodeExtension
Section titled “Retain node-local behavior with NodeExtension”A retained modifier attaches one NodeExtension instance to one mounted node.
This is the boundary for behavior that must survive compatible recomposition but is not application state: a recognizer, animation clock, observation token, text client, semantic contributor, or paint-visible effect.
The public pattern has two parts:
struct ActivityPulse;
class ActivityPulseExtension final : public NodeExtension {public: ActivityPulseExtension(ViewNode&, const ActivityPulse& pulse); void Update(ViewNode&, const ActivityPulse& pulse); FrameResult OnFrame(ViewNode&, const FrameInfo& frame) override; void PaintAboveContent(const ViewNode&, PaintContext& context) const override;
private: Color color_; float phase_ = 0.0F;};
struct ActivityPulse { using Extension = ActivityPulseExtension;
Color color; bool operator==(const ActivityPulse&) const = default;};Construction starts retained state, while Update applies new declarative configuration without resetting compatible state.
OnFrame returns whether another frame is needed; when retained state changes visible output, call protected InvalidatePaint(...) before returning.
Paint through PaintBehindContent or PaintAboveContent instead of mutating the component tree.
Do not retain a raw ViewNode*, request frames through a platform callback, or add a concrete-extension branch to Runtime.
Implement only the NodeExtension capabilities the behavior owns, and invalidate semantics separately when semantic output changes.
Here is a complete paint-only retained modifier. Ordinary application borders should use Border; this deliberately small example illustrates extension lifetime without another scheduler or node wrapper:
#include <huxerui/huxerui.h>
using namespace huxerui;
struct DiagnosticOutline { class Extension; Color color = Color::Rgb(190, 40, 40); bool operator==(const DiagnosticOutline&) const = default;};
class DiagnosticOutline::Extension final : public NodeExtension {public: Extension(ViewNode& node, const DiagnosticOutline& value) { Update(node, value); }
void Update(ViewNode&, const DiagnosticOutline& value) { color_ = value.color; InvalidatePaint(); }
void PaintAboveContent(const ViewNode& node, PaintContext& paint) const override { paint.DrawBorder(node.Bounds(), color_, StrokeStyle{.width = 1.0F}); }
private: Color color_;};
View OutlinedContent() { return Text("Measured content").With(Padding(12.0F), DiagnosticOutline{});}ViewNode::Bounds() is node-local geometry including resolved padding; ContentBounds() excludes that padding, not borders. PresentationBounds() is a window-space axis-aligned box, not the visible clipped region. Use the matching space for paint and hit testing; borrow ViewNode only for the callback.
Emit semantic output with typed events
Section titled “Emit semantic output with typed events”Define an event key when a composed control produces a reusable semantic result:
struct RatingChanged : Event<void(int)> {};
[[huxerui::composable]]View Rating(int value) { return Row { Button("Lower").OnClick([events = UseEvents(), value] { events.Emit<RatingChanged>(value - 1); }), Text::Format("{}", value), Button("Raise").OnClick([events = UseEvents(), value] { events.Emit<RatingChanged>(value + 1); }), };}
Rating(rating).On<RatingChanged>([rating](int next) { rating = next;});The event-key type is the identity.
Do not add a second callback property when .On<Key>() already models the output, and do not hide the current controlled value inside the component.
Share inherited configuration
Section titled “Share inherited configuration”Use an Environment key for typed hierarchical configuration that descendants read during composition. Use Theme when the value is visual policy shared by themed components. These are propagation mechanisms, not owners of mutable services or substitutes for explicit required arguments.
Use a root service for a per-window capability with one authoritative retained owner:
This complete installation keeps the last applied document text for the window lifetime. Editors own their draft State and explicitly apply it to the service; this in-memory example does not claim to save a file:
#include <huxerui/huxerui.h>
#include <memory>#include <string>
using namespace huxerui;
struct DocumentSession { std::string saved_text = "Untitled document";};
AppOptions Options() { AppOptions options; options.root_hooks.push_back([](RootContext& root) { root.Provide(std::make_shared<DocumentSession>()); }); return options;}
[[huxerui::composable]]View DocumentEditor() { auto document = UseService<DocumentSession>(); auto draft = UseState(TextEditingValue::FromText(document->saved_text)); return Column { TextField(draft) .Label("Document") .OnChanged([draft](const TextEditingValue& next) { draft = next; }), Button("Apply to session").OnClick([document, draft] { document->saved_text = draft->text; }), }.With(Spacing(8.0F));}
View App() { return DocumentEditor();}
const Application application{App, Options()};Choose a root service only when lifetime follows the Runtime/window and multiple scopes need coordinated operations. Ordinary state, theme values, and component configuration remain in composition or Environment.
Provide rejects a null service and a second provider of the same service type. UseService<T>() fails if that exact type is not installed; it does not search derived classes or construct a missing service. In a single local form, prefer UseState and pass the value explicitly—root installation is useful only when the service really owns window-wide coordination.
A service field is not automatically observable: an already-open second editor does not refresh merely because saved_text changed. Use an explicit observation contract when a real document service needs live updates; do not manufacture State cells through internal constructors during root installation.
Extend rendering or the platform boundary
Section titled “Extend rendering or the platform boundary”Canvas and existing PaintCommand variants cover most custom graphics.
A new command is a cross-platform rendering contract: it carries immutable platform-neutral data and requires implementation in every supported renderer.
Add one only when paths, images, text runs, transforms, clips, opacity, and shadows cannot represent the operation correctly and efficiently.
Native integration has three distinct boundaries:
| Need | Boundary | Owner |
|---|---|---|
| Call an SDK service without embedding a control | PlatformModule |
platform module instance for the Runtime |
| Place an actual platform control in HuxerUI layout | PlatformView |
platform view controller and adapter |
| Present producer-owned GPU or media content | ExternalTexture |
producer plus platform texture surface |
Do not use NodeExtension as an escape hatch for native handles.
Do not simulate an embedded control with PlatformModule, or use PlatformView for an operation that has no native visual surface.
Choose the narrowest boundary
Section titled “Choose the narrowest boundary”| Need | Extension |
|---|---|
| Reusable UI from existing components | Function returning View |
| Local state or recomposition identity | Scoped function component |
| Reusable node data already modeled by HuxerUI | Existing property modifier |
| Parent-specific child metadata | LayoutValue<Key> |
| New measurement and placement policy | Layout<Derived> |
| New visible-window data geometry | VirtualLayout<Derived> |
| Per-node retained behavior | Retained modifier and NodeExtension |
| Typed semantic output | Event key and .On<Key>() |
| Inherited configuration | Environment or Theme |
| Per-window retained capability | Root service |
| New platform-neutral drawing operation | PaintCommand |
| Native SDK capability | PlatformModule |
| Embedded native control | PlatformView |
If two rows appear to fit, start with the earlier and narrower one.
Move outward only when ownership, lifetime, or platform behavior cannot be expressed correctly there.
PaintCommand variants and new property-modifier descriptors are framework extension work; the other rows have public application-facing authoring paths at the pinned revision.

