ellipog

Property panels

Minecraft 1.21.1Fabric + NeoForgeArmature 0.2.0

A property editor over other people's types is a panel over a set it cannot enumerate: an addon arrives with a kind this build has never heard of, and the panel still has to show something. The ui.inspect package is that machinery — rows, typed fields, layout metrics and a registry with a fallback — so a mod supplies only what a field of its own type means.

InspectRow: what a panel is made of#

A panel is a list of rows, and a row knows what it is rather than only what it says:

List<InspectRow> rows = List.of(
        InspectRow.heading("counts", "Counts"),
        InspectRow.field("count", "Count", "8"),
        InspectRow.toggle("consume", "Consume items"),
        InspectRow.action("pick", "Pick an item…"),
        InspectRow.warning("missing", "This item does not exist in this build"));

The kinds are FIELD (label, editable value in the strip at its right), VALUE (read-only), TOGGLE (two-state control), ACTION (the whole row is a control), HEADING, ENTRY (a list entry's name with its own controls), WARNING (a section name in the voice that says something is wrong), and RAW (a value too long or too strange for a field, editable as a whole). hasStrip(), isControl() and isHeading() are the questions a renderer asks rather than switching on the kind itself.

InspectField<T>: how a value becomes text and back#

public interface InspectField<T> {
    String label();
    String format(T value);
    Result<T> parse(String text);   // Result.ok(value) or Result.bad("why not")
}

This is where the honest failures live. A field that accepts "twenty" for a count and commits it as zero is not a drawing fault or an arithmetic fault, and no screenshot shows it — so parse answers with a value or a reason, and the reason is drawn beside the row. The package ships the ordinary ones — text, integers with bounds, and so on — and a mod writes one for a type only it understands.

InspectLayout: metrics, and two shapes of row#

Layout is not the panel's business, so the metrics and the stack live here:

ROW_HEIGHT 18, HEADING_HEIGHT 15, ROW_GAP 1, SECTION_GAP 7The side-by-side metrics.
STACKED_LABEL_HEIGHT 11, STACKED_CONTROL_HEIGHT 18, gaps 4 / 9The stacked metrics.

Mode.SIDE_BY_SIDE puts the label left and the control in the strip at its right; Mode.STACKED puts the label on its own band and the control across the row beneath it. Which one fits is a property of the panel rather than of a row — a narrow panel stacks — so the mode is the caller's, and stack(rows[, mode]) and build(rows, width, measure[, mode]) take it. A side-by-side row with a control reserves the strip with stripRoom().

InspectPanels<V>: one registry, and the fallback that is part of it#

InspectPanels<MyValue> panels = new InspectPanels<MyValue>()
        .register("mymod:counted", (type, value) -> rowsForCounted(value))
        .fallback((type, value) -> List.of(InspectRow.warning("unknown", "No panel for " + type)));

List<InspectRow> rows = panels.rowsFor(type, value);   // never null

register(type, panel) is per type and re-registering replaces, deliberately. fallback(...) is what an unregistered type is shown as, and it is part of the registry rather than an error path: "this build cannot edit that" is still an answer a reader can act on. known(type) says whether a panel was written — the fallback is not a panel, and does not count.

The party roster#

ui.party is the other model a panel draws. PartyRoster.of(team, viewer, names, online) turns a Team into rows that already know what the viewer may do:

PartyRoster roster = PartyRoster.of(team, viewerId, nameLookup, onlineLookup);
for (PartyRoster.Member m : roster.members()) {
    // m.key(), m.removeKey() and m.transferKey() place the row and its Remove and
    // Transfer buttons; m.label() says the name, with "(you)" appended when it is
    // the viewer, and m.roleLabel() the role the way the command spells it.
}

A Member carries the id, name, role, and five facts a panel would otherwise re-derive — self, owner, canRemove and canTransfer (the same rules the server will apply) and online. One of those is worth calling out: isReal() — a question asked of the roster rather than of a member — distinguishes "a party of one you just created" from "you are alone", which a panel inferring it from size() == 1 would get wrong for the ten seconds after the party is made.

Each row reserves PartyRoster.actionStrip() on its right — room for Transfer and Remove, laid out by transferSlot and removeSlot from the row's own slot. It is reserved on every row, including the ones no control is drawn on, so two rows never have different text widths for a reason nothing on screen explains. A consumer that draws a role badge from the same strip measures it against the same actionStrip(), which is what keeps the chip and the hover controls one place rather than two.

From a wire-format rather than a live team, PartyRoster.fromParts(..., policy, memberLimit) builds the same roster from what the snapshot carried — canRemove and friends recomputed from the roles rather than sent, because a boolean that travels can disagree with the roles it came from.

The roster also answers what the viewer may do with the party as a whole — canLeave, canDisband, canInvite, canRename, canSetPolicy — and what the party is: memberCount, memberLimit with hasMemberLimit, membersCanInvite, openJoin, and removableCount for the rows a Remove button may sit on. A panel asks these rather than re-deriving them from roles, for the same reason a kick button asks canActOn.

Support the workko-fi.com/ellipog