-
Notifications
You must be signed in to change notification settings - Fork 1
Composable UI Reference
Compound 2.0 for Minecraft 26.3. Introduced by PR #4; see the PR for merge status. Start with the screen guide.
Compound is a declarative, composition-based UI system for Minecraft mods. You describe the
structure of a UI once with nested e(...) calls; the structure is then "baked" and only rebuilds
where you explicitly opt into state binding. Rendering happens every frame, so ordinary property
reads (text, color) need no binding at all.
Current integration: Minecraft 26.3, NeoForge 26.3.0.26-beta, ModDevGradle 2.0.147.
This is the broad framework reference: architecture, layout, state, events, authoring custom elements, patterns, and testing. For the exhaustive consumer API — every scope method, layout property, slot key, and built-in widget — see consumer API.
| Kind | Interface | Purpose | Examples |
|---|---|---|---|
| Primitive | IPrimitiveElement |
Draws pixels; no children |
Rect, GradientRect, Sprite, Text, Spacer, ItemDisplay
|
| Container | IContainer |
Arranges children by a layout algorithm |
Stack, Column, Row, Box, Grid
|
| Composable | IComposableElement |
Encapsulates state/behavior with internal composition; customized via slots |
Panel, Button, Label, ScrollArea, TextInput, pickers |
IElement is the common base; IGenericElement / IGenericContainer are the generic roots that
measure/place are defined on. The tree itself is owned centrally by UITree — elements do not
hold their own child lists, which is what makes the "baked structure + explicit binding" model
enforceable.
-
Element— implementsIElementInternal; stores the node reference, suppliesgetBounds(),getNode(),setNode(), and default no-op lifecycle. Concrete elements implementmeasureandplace. -
PrimitiveElement— adds a finaldraw(IScreenContext)that wraps yourdrawElement(IScreenContext, Bounds)with visibility handling, and an emptyplace. -
Container— minimal base for container elements.
public class MyScreen extends ComposedUI {
@Override
protected void compose(ICompositionScope scope) {
scope.e(new Stack(), stack -> {
stack.layout().fillMax().contentAlignment(Alignment.CENTER);
});
}
}For inventory screens, extend ComposedUIContainer<T extends CompoundContainerMenu> and construct it
with (menu, inventory, title). It discovers InventorySlot elements automatically and manages
hover/quick-craft/carried-item visuals.
Both classes handle measure/place/render and dispatch mouse, keyboard, scroll, drag, and focus events
into the tree. You only implement compose(ICompositionScope scope). Screen-context access uses
IScreenContext. The scope still exposes concrete UITree through getTree() for advanced
overlay, viewport, focus, and hit-testing work; ordinary composition does not require it.
Elements are added with scope.e(element, configurator); the configurator type is chosen from the
element's static type:
- primitive →
IElementScope<T>(getElement(),layout()) - container →
IContainerScope<T>(+ e(...), events,bind/bindLayout, animations) - composable →
IComposableElementScope<T>(getElement(),layout(),fillSlot(...))
scope.e(new Column(), column -> {
column.layout()
.fillMaxWidth()
.spacing(10)
.padding(20)
.horizontalAlignment(Alignment.CENTER);
column.e(new Button(), button -> {
button.layout().fixedSize(100, 40);
button.fillSlot(Button.CONTENT_SLOT, content ->
content.e(new Label(Component.literal("Click me"))));
});
});e(element) with no configurator is shorthand for "add, no configuration". Events and
bind/bindLayout exist on container/root scopes and inside a composable's own compose — not on
the configurator handed to a composable's user.
A composable publishes public static final SlotKey constants for the regions it allows callers to
override:
public class MyPanel extends Element implements IComposableElement {
public static final SlotKey HEADER_SLOT = new SlotKey("header");
public static final SlotKey CONTENT_SLOT = new SlotKey("content");
@Override
public void compose(ICompositionScope scope) {
scope.e(new Column(), column -> {
column.slot(HEADER_SLOT, fallback ->
fallback.e(new Label(Component.literal("Default Header"))));
column.slot(CONTENT_SLOT);
});
}
}
scope.e(new MyPanel(), panel -> {
panel.fillSlot(MyPanel.HEADER_SLOT, header ->
header.e(new Label(Component.literal("Custom Header"))));
});If the caller filled a slot, their content wins; otherwise the default lambda runs. SlotKey
compares by name, but each composable has its own slot map, so identical names across components do
not collide.
Built-in slot keys: Panel.CONTENT_SLOT, Button.CONTENT_SLOT, ScrollArea.CONTENT_SLOT,
Surface.CONTENT_SLOT.
Layout is two-phase:
-
Measure (bottom-up).
measure(Constraints, LayoutProperties, List<Size> measuredChildren)returns the element's intrinsic size. The tree measures children first and applies their margins. -
Place (top-down).
place(Bounds, LayoutProperties, List<Size> measuredChildren)returns aBoundsper child (including margin space). The tree offsets by each child's margin and recurses.
@Override
public Size measure(Constraints constraints, LayoutProperties props, List<Size> children) {
int w = Math.min(desiredWidth, constraints.maxWidth());
int h = Math.min(desiredHeight, constraints.maxHeight());
return new Size(w, h);
}
@Override
public List<Bounds> place(Bounds bounds, LayoutProperties props, List<Size> children) {
List<Bounds> result = new ArrayList<>();
int y = bounds.y();
for (Size child : children) {
result.add(new Bounds(bounds.x(), y, child.width(), child.height()));
y += child.height() + props.getSpacing();
}
return result;
}Bounds is a record (Position position, Size size) with an (int x, int y, int w, int h)
constructor and helpers: x/y/width/height, left/top/right/bottom, contains, intersects,
intersection, offset, shrink, expand.
Constraints is a record (minWidth, maxWidth, minHeight, maxHeight) with factories:
Constraints.unbounded() // no limits
Constraints.fixed(200, 100) // exact
Constraints.loose(200, 100) // up to
Constraints.tight(200, 100) // alias of fixedHelpers: hasFixedWidth/Height, hasBoundedWidth/Height, isUnbounded, constrain(Size),
constrainWidth/Height, withMaxWidth/Height, withMinWidth/Height, withFixedWidth/Height,
withFixedSize, deflate(horizontal, vertical).
Reached via scope.layout() (or element.layout() inside a composable's compose). Full details in
the consumer doc; summary:
-
Size:
fixedWidth/Height,fixedSize,fillMaxWidth/Height,fillMax,minWidth/Height,maxWidth/Height,clearMaxWidth/Height,unboundedWidth/Height,weight -
Spacing:
padding(...),margin(...)(1, 2, or 4 argument forms),spacing(int) -
Alignment:
contentAlignment(Box/Stack),horizontalAlignment(Column),verticalAlignment(Row) -
Other:
clip(),layer(int),gridSize(columns, rows),deferred(Consumer<DeferredScope>)
maxWidth and maxHeight cap within the parent constraints, including when passed
Integer.MAX_VALUE. Use unboundedWidth() or unboundedHeight() explicitly for scroll content;
clearMaxWidth() / clearMaxHeight() remove the explicit cap and restore the parent limit.
Grid spacing is constructor-level (new Grid(columns, hSpacing, vSpacing)), not spacing().
Each frame advances attached timelines, settles pending composition/layout work, runs beforeGeometry callbacks, then samples transform suppliers. Drawing, hit testing, and transformed debug outlines use the resulting FrameGeometry snapshot. Nested transforms compose as parent × child; each local transform scales and rotates around its pivot before translation. The pivot is measured from the element's untransformed top-left in GUI pixels. A visual transform leaves measured size and placed layout bounds unchanged.
Use beforeGeometry to prepare render-only fields before any transform is sampled. Keep draw and transform suppliers read-only: state, layout, or transform mutations during drawing can desynchronise pixels and input from the sampled geometry. Structural and measured-size changes belong in input callbacks and state/layout bindings.
Mouse event coordinates remain world GUI coordinates. Convert them through the configured element scope when implementing transformed controls:
scope.e(new Stack(), stack -> {
stack.layout().fixedSize(80, 40);
stack.transform(() -> ITransform2D.rotation(Math.PI / 6, 40, 20));
stack.onClick(event -> {
var local = stack.toLocal(new WorldPoint(event.x(), event.y()));
if (local.isEmpty()) return false;
System.out.println(local.get().x() + ", " + local.get().y());
return true;
});
});Import ITransform2D and WorldPoint from com.tridevmc.compound.ui.geometry.api. toLocal(double, double) is also available. The result is Optional<LocalPoint>, measured from the element's top-left. Missing frame geometry or a singular transform, such as zero scale, returns an empty result. Hit testing inverse-maps the pointer and checks local bounds plus the effective clip. Conversion alone does not check containment.
Clipping uses axis-aligned GPU scissors shared by rendering and hit testing. Nested clips intersect. A positive-layer overlay starts outside its ancestor's clip while retaining the ancestor transform. Translation and axis-aligned scaling can clip; a clipping node whose composed transform has rotation/off-axis terms throws UnsupportedOperationException. This includes rotation inherited by a clipped descendant. Rotated clipping needs a stencil backend and is unsupported. Text inputs clip their content, so rotating their subtree encounters this restriction.
Transforms do not add antialiasing. Rotated edges and text can show aliasing with the current rendering backend; smooth antialiased output remains a quality limitation.
State<T> is the observable container (State.of(initial)): get(), set(value), update(fn),
dispose(), plus framework-internal observer registration.
State<T>does not extendSupplier<T>. Elements that accept a supplier need() -> state.get()(or the element'ssetXxxSuppliersetter).
scope.bind(username); // state change => recompose this subtree (structure)
scope.bindLayout(columns); // state change => remeasure/re-place only-
bind/bindComposition— conditional rendering, list rebuilds, swapping children. A container binding replays its configurator and replaces its children and composition-owned resources. -
bindLayout— size/position-only changes; skips recomposition. - No binding — draw suppliers are read on each render. Suppliers that change measured size still need layout invalidation; measurement and placement do not run every frame.
var width = scope.animateInt(0, 300);
box.layout()
.fillMaxHeight()
.deferred(deferred -> {
deferred.bind(width);
deferred.layout().fixedWidth(width.get());
});DeferredScope re-runs its callback when a bound state changes and requests layout. Its
subscriptions are removed with the owning composition or node.
Create animated values from the scope. Values created inside a composition are disposed when
that composition runs again. Use scope.retainAnimation(value) for an animation cached in an
element field; it then survives recomposition and is disposed when the element detaches. Clear
the cached field in onDetached() so a later attachment can create a fresh animation.
Animations created by a composable element's outer configurator belong to that node's lifetime.
var value = scope.animateFloat(0f, 500); // EASE_IN_OUT default
var num = scope.animateInt(0, 500);
var color = scope.animateColor(0xFF000000, 500, Easing.EASE_OUT_QUART);
var pulse = scope.animateFloatLooping(0.4f, 1.0f, 800); // STEP default
var blink = scope.animateIntLooping(0, 1, 500, Easing.EASE_IN_OUT);
var cycle = scope.animateColorLooping(0xFF0000, 0x0000FF, 1000);
value.set(1f); // animate toward target
value.get(); // current tick value
value.get(partialTicks); // interpolated for smooth drawing
value.setImmediate(0f); // jump without animating
scope.retainAnimation(value); // only when keeping it across compositionsAnimatedState<T> retains the generic State<T> contract, so numeric values are boxed
at that boundary. A drawing IntSupplier or DoubleSupplier can adapt animation::get
by unboxing; primitive supplier signatures do not remove boxing inside generic state.
Interpolators provides FLOAT, INT, COLOR. Easing provides LINEAR, EASE_IN,
EASE_OUT, EASE_IN_OUT, EASE_IN_CUBIC, EASE_OUT_CUBIC, EASE_IN_OUT_CUBIC,
EASE_IN_OUT_SINE, EASE_OUT_QUART, EASE_IN_OUT_QUART, and STEP.
Handlers are registered on a container/root scope, or inside a composable's compose. Boolean
handlers return true to consume (stop bubbling) and false to let the parent handle it.
scope.onClick(event -> {
int x = event.x(), y = event.y();
return true;
});
scope.onScroll(event -> {
double delta = event.scrollY();
return false;
});
scope.onScrollWhenFocused(event -> true); // only consumes when this node is focused
scope.onMouseEnter(() -> hovered.set(true));
scope.onMouseExit(() -> hovered.set(false));Available handlers: onClick, onScroll, onScrollWhenFocused, onKeyPress, onKeyRelease,
onCharTyped, onMouseRelease, onMouseDrag, onMouseMove, onMouseEnter, onMouseExit,
onFocusGained, onFocusLost, plus requestFocus() / isFocused().
Event records: MouseClickEvent(x, y, button, shiftDown, ctrlDown, altDown),
MouseReleaseEvent(x, y, button), MouseDragEvent(button, x, y, deltaX, deltaY),
MouseMoveEvent(x, y, prevX, prevY), MouseScrollEvent(x, y, scrollX, scrollY),
KeyInputEvent(keyCode, logicalKeyCode, shiftDown, ctrlDown, altDown), CharEvent(codePoint, modifiers).
Character input uses an integer Unicode code point; insert it with Character.toChars(codePoint)
rather than casting to char. Use native InputConstants for key and mouse-button comparisons.
Events dispatch from the deepest hit node outward (target → parent → ...) until consumed.
import java.util.function.DoubleSupplier;
import java.util.function.IntSupplier;
public class Circle extends PrimitiveElement {
private final IntSupplier color;
private final DoubleSupplier radius;
public Circle(IntSupplier color, DoubleSupplier radius) {
this.color = color;
this.radius = radius;
}
@Override
public Size measure(Constraints constraints, LayoutProperties props, List<Size> children) {
int diameter = (int) (radius.getAsDouble() * 2);
return new Size(
Math.min(diameter, constraints.maxWidth()),
Math.min(diameter, constraints.maxHeight()));
}
@Override
protected void drawElement(IScreenContext context, Bounds bounds) {
double r = Math.max(0, Math.min(radius.getAsDouble(),
Math.min(bounds.width(), bounds.height()) / 2.0));
float centerX = bounds.x() + bounds.width() / 2.0f;
float centerY = bounds.y() + bounds.height() / 2.0f;
int argb = color.getAsInt();
for (int y = (int) Math.ceil(-r); y < r; y++) {
double rowOffset = y + 0.5;
float halfWidth = (float) Math.sqrt(Math.max(0, r * r - rowOffset * rowOffset));
context.drawRect(centerX - halfWidth, centerY + y, halfWidth * 2, 1, argb);
}
}
}Implement measure/place as shown in Layout system. A flow layout, wrapping
example, etc. all follow the same contract: measure intrinsic size from measured children, then
return one Bounds per child.
public class Card extends Element implements IComposableElement {
public static final SlotKey CONTENT_SLOT = new SlotKey("content");
public static final SlotKey ACTIONS_SLOT = new SlotKey("actions");
private final State<Boolean> hovered = State.of(false);
@Override
public void compose(ICompositionScope scope) {
scope.bind(hovered);
scope.onMouseEnter(() -> hovered.set(true));
scope.onMouseExit(() -> hovered.set(false));
scope.e(new Stack(), stack -> {
stack.layout().fillMax();
stack.e(new Rect(hovered.get() ? 0xFF444444 : 0xFF222222),
bg -> bg.layout().fillMax());
stack.e(new Column(), column -> {
column.layout().fillMax().padding(16);
column.slot(CONTENT_SLOT);
column.e(new Spacer(0, 16));
column.e(new Row(), row -> {
row.layout().fillMaxWidth();
row.slot(ACTIONS_SLOT);
});
});
});
}
@Override
public Size measure(Constraints constraints, LayoutProperties props, List<Size> children) {
return children.isEmpty() ? new Size(0, 0) : children.get(0);
}
@Override
public List<Bounds> place(Bounds bounds, LayoutProperties props, List<Size> children) {
return children.isEmpty() ? List.of() : List.of(bounds);
}
}Composables also typically expose a small setter API (e.g. setText, setEnabled), state getters
for external binding, and listener registration methods.
- Pick the right element type. Primitive for pixels, container for layout only, composable for behavior + internal structure + slots.
-
Use the narrowest binding.
bindLayoutfor size/position changes;bindonly for structural changes; plain suppliers for per-frame value reads. - Keep state local to the component that owns it.
-
Don't do work in
measure/place. They are pure functions of their inputs. - Return the right boolean from event handlers to control bubbling.
-
Use
layer(...)for overlays (dropdowns, modals, tooltips). - Use
clip()for scroll/overflow containers.
public class Counter extends Element implements IComposableElement {
private final State<Integer> count = State.of(0);
@Override
public void compose(ICompositionScope scope) {
scope.bind(count);
scope.e(new Column(), column -> {
column.layout().horizontalAlignment(Alignment.CENTER);
column.e(new Label(() -> Component.literal("Count: " + count.get()),
() -> 0xFFFFFF, () -> true));
column.e(new Row(), row -> {
row.layout().spacing(10);
row.e(new Button(), button -> {
button.fillSlot(Button.CONTENT_SLOT, content ->
content.e(new Label(Component.literal("-"))));
button.getElement().addPressListener((x, y) ->
count.update(c -> Math.max(0, c - 1)));
});
row.e(new Button(), button -> {
button.fillSlot(Button.CONTENT_SLOT, content ->
content.e(new Label(Component.literal("+"))));
button.getElement().addPressListener((x, y) ->
count.update(c -> c + 1));
});
});
});
}
}scope.e(new Column(), column -> {
column.bind(showDetails); // recompose this column when the flag changes
column.e(new Button(), button -> {
button.fillSlot(Button.CONTENT_SLOT, content ->
content.e(new Label(Component.literal(showDetails.get() ? "Hide" : "Show"))));
button.getElement().addPressListener((x, y) ->
showDetails.set(!showDetails.get()));
});
if (showDetails.get()) {
column.e(new Label(Component.literal("Detailed information")));
}
});scope.e(new Column(), column -> {
column.bind(items);
for (String item : items.get()) {
column.e(new Label(Component.literal(item)), label ->
label.layout().fillMaxWidth());
}
});public class AnimatedAccordion extends Element implements IComposableElement {
private final State<Boolean> expanded = State.of(false);
private AnimatedState<Float> height;
@Override
public void compose(ICompositionScope scope) {
if (height == null) {
height = scope.animateFloat(0f, 300);
}
scope.retainAnimation(height);
scope.bind(expanded);
scope.onClick(event -> {
expanded.set(!expanded.get());
height.set(expanded.get() ? 200f : 0f);
return true;
});
scope.e(new Column(), column -> {
column.e(new Rect(0xFF333333), header ->
header.layout().fillMaxWidth().fixedHeight(40));
column.e(new Box(), box -> {
box.layout().fillMaxWidth().fixedHeight((int) (float) height.get())
.deferred(deferred -> {
deferred.bind(height);
deferred.layout().fixedHeight((int) (float) height.get());
});
box.e(new Label(Component.literal("Accordion Content")), label ->
label.layout().padding(20));
});
});
}
@Override
public void onDetached() {
height = null;
}
// measure() / place() as for any composable
}public class MyUIScreen extends ComposedUI {
@Override
protected void compose(ICompositionScope scope) {
scope.e(new Column(), column -> {
column.layout().fillMax().padding(20);
column.e(new Label(
Component.literal("My UI Screen").withStyle(ChatFormatting.BOLD)));
column.e(new Spacer(0, 20));
// ...
});
}
}public class MyCrateScreen extends ComposedUIContainer<MyCrateMenu> {
public MyCrateScreen(MyCrateMenu menu, Inventory inv, Component title) {
super(menu, inv, title);
}
@Override
protected void compose(ICompositionScope scope) {
scope.e(new Panel(), panel -> {
panel.layout().fixedSize(178, 190);
panel.fillSlot(Panel.CONTENT_SLOT, content ->
content.e(new Grid(9, 0, 0), grid -> {
for (int i = 0; i < 27; i++) {
grid.e(new InventorySlot(menu.getSlot(i)));
}
}));
});
}
}Open it the same way you open any Screen / AbstractContainerScreen.
Composition is testable without a running client: build a UITree, compose through ICompositionScope.root(tree),
then measure, place, and render against a mocked IScreenContext.
@Test
void testLayout() {
var tree = new UITree();
var scope = ICompositionScope.root(tree);
scope.e(new Stack(), stack -> {
stack.layout().fillMax().contentAlignment(Alignment.CENTER);
stack.e(new Panel(), panel -> panel.layout().fixedSize(178, 190));
});
tree.measureTree(new Constraints(0, 800, 0, 600));
tree.placeTree(new Position(0, 0), new Constraints(0, 800, 0, 600));
var panel = tree.getRoot().getChildren().getFirst().getElement();
assertEquals(new Bounds(311, 205, 178, 190), panel.getBounds());
}Useful UITree members for tests: measureTree(Constraints), placeTree(Position[, Constraints]),
renderTree(IScreenContext), dispatchClick(x, y, event), dispatchScroll(x, y, event),
dispatchKeyPress/Release, dispatchCharTyped, dispatchMouseMove/Drag/Release, getRoot(),
walkDepthFirst(node, visitor), findNodesByElementType(Class), findNodeAt(x, y),
getFocusedNode(), requestFocus(node), bindNodeToState(node, state).
The project's own regression suites (CrateUIIntegrationTest, TextInputIntegrationTest) validate
exact bounds and draw calls this way.
-
Minimize recomposition — prefer
bindLayout, keep state scoped, avoid over-broadbind. - Cheap measurement — respect constraints, avoid O(n²) work, cache expensive content sizes.
-
Clipping — use
clip()to bound draw work for overflow/scroll containers. -
Animations — prefer draw-only reads of animated values;
bindLayoutonly when size/position actually change. Bound animated values update at tick rate, with smoothness from partial-tick reads.
| Symptom | Likely cause |
|---|---|
| UI not updating | State not bound (bind/bindLayout), or a state mutated without set/update
|
| Layout not recalculating | Layout property changed without bindLayout or deferred
|
| Wrong layout | Constraint misuse in measure, or margin/padding expectations in place
|
| Events not firing | Handler not registered (or registered on a composable's user configurator, which has no events); event consumed by a child first |
| Compile error adding a child | The element is a primitive/composable, not a container — use slots for composables |
| Overlay hidden behind siblings | Give it layout().layer(1) or higher |
Debug options: UITree.DEBUG_EVENTS (-Dcompound.ui.debugEvents=true) logs event dispatch;
DebugOverlayConfig (toggled with F3+B in a composed screen) draws layout bounds.
- Classify existing widgets: pixels → primitive, layout → container, behavior → composable.
-
Replace field mutation with
State<T>and bind where structure or layout depends on it. -
Replace absolute positioning with layout properties (
fillMax,padding,spacing, alignment,fixedSize). -
Extract reusable widgets as composables that publish
SlotKeys instead of exposing internals. - Migrate incrementally, starting with leaf widgets.
Retired names you may encounter in older docs map as: ElementLabel → Label, ElementBox →
Panel/Surface, ElementRect → Rect, ElementImage/ElementSprite → Sprite,
ElementItem → ItemDisplay, ElementSpacer → Spacer, ElementSlot → InventorySlot,
ICompositionContext → ICompositionScope, LayoutProperties.withFixedSize(...) → layout().fixedSize(...).
For the complete consumer-facing surface (every scope method, layout property, slot key, and widget constructor), see consumer API.