Widgets and screens
Minecraft 1.21.1Fabric + NeoForgeArmature 0.2.0
The controls the kit ships are ordinary AbstractWidgets, so focus, narration, tab order and hover
all work the way the game already makes them work. What the kit adds is that they draw through the
renderer seamSeamThe one boundary where two halves of a system meet so neither has to know the other. Armature has two: the loader seam, which is the platform layer over Fabric and NeoForge, and the renderer seam, which keeps the whole toolkit free of game classes. and take their colours from the palette.
Controls#
ArmatureButton claim = new ArmatureButton(x, y, 90, 20, Component.literal("Claim"), this::claim);
claim.accent(true); // the one control a panel is steering towards
claim.borderless(true); // for a row that is already inside a boxArmatureButton also has selected(...) and flat(...), so one class covers the loud button, the
quiet one and the row that behaves like a choice. A button's picture need not be an item:
texture(id) draws a texture file before the label and sprite(id) an atlas region, the item
winning when set beside either — one icon arm per control — and iconInset says how far the
sprite sits inside the edge.
ArmatureTextField and ArmatureTextArea are the editing widgets; the models under them are on
Text models.
ArmatureTextField name = new ArmatureTextField(x, y, width, 18, "my_pack");
name.onSubmit(this::commitName);ArmatureScreen: a panel that follows its data#
Extend ArmatureScreen instead of Screen and a panel updates itself when the state behind it moves:
public final class MyPanel extends ArmatureScreen {
public MyPanel() { super(Component.literal("My panel")); }
@Override
protected void renderContent(GuiRenderer r, int mouseX, int mouseY, float partialTick) {
// Everything this panel draws, through the seam.
}
@Override
protected void rebuildWidgets() {
// Recreate the controls for the current state.
}
}Three things come with the base class:
renderContentis the drawing hook. The game's graphics type is wrapped once here, so a subclass draws throughGuiRendererand never names the type underneath. Drawing happens before the widget pass, which is the order a panel wants: background, then controls.- Vanilla's background is suppressed — no blur, no menu gradient. A panel that draws its own card
would otherwise get the game's fade landing between its drawn half and its widget half, which is
invisible in the code and obvious on screen. A subclass that wants the vanilla background back
overrides
renderBackgroundagain. - A watched value moving calls
rebuildWidgets()for you — see below.
ArmatureLive: what a screen watches#
The problem this solves is small and universal: a screen is built once, while the things it describes — a party's members, saved progress, a reward waiting — change underneath it. Each panel that grows its own "has anything changed" check is a panel that can forget one.
So the data registers itself, once, where it lives:
ArmatureLive.watch("party", () -> MyState.partyRevision());
ArmatureLive.watch("rewards", () -> MyState.rewardRevision());Every ArmatureScreen checks those numbers once per frame; when one has moved, it rebuilds — every
screen, including screens written later, with nothing to remember and no field to add. revisions()
and watched() are there for a diagnostic screen that wants to show the numbers.
The check is in the frame rather than on the message that changes the data, deliberately: a message handler runs on whichever thread received it, at whatever moment, possibly while the screen is half-drawn — and a widget list rebuilt from a network thread is a crash rather than a redraw. The frame loop is the one place always on the right thread and never in the middle of anything. The cost is a comparison of a few numbers per frame.