Skip to content

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.

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.

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.

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.

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.

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.

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.