Skip to content

Machine UIs

When any machine that extends IMuiMachine or has a machine panel in its definition is right-clicked, it will attempt to open a UI. Implementing IMuiMachine on a MetaMachine subclass is all that is needed to get a working panel:

public class MyMachine extends MetaMachine implements IMuiMachine {

    public MyMachine(BlockEntityCreationInfo info) {
        super(info);
    }
}

Right-clicking the machine now opens a panel with a title bar and the player inventory attached. See Making a MUI2 Test Machine for a machine definition you can register to try this out.

How a machine panel is built

IMuiMachine splits UI creation into a few overridable steps. The order they run in determines which one you want to override:

default ModularPanel<?> buildUI(PosGuiData data, PanelSyncManager syncManager, UISettings settings) {
    var panelBuilder = getPanelBuilder(data, syncManager, settings);       // (1)
    panelBuilder.mainContents(parent -> buildMainUI(parent, data, syncManager, settings)); // (2)
    return panelBuilder.build(syncManager, settings);                      // (3)
}
  1. getPanelBuilder returns a MachineUIPanelBuilder, which decides what the frame of the panel looks like: title bar, player inventory, configurator buttons.
  2. buildMainUI fills the content area in the middle of that frame. This is the method to override in almost every case.
  3. build assembles everything into a MachineUIPanel.

Note

buildUI, buildMainUI and getPanelBuilder all run on both the client and the server. Any value read from the machine has to be wrapped in a sync handler before a widget can display it. See Sync Basics.

buildMainUI

buildMainUI receives the panel's main content area as a ParentWidget<?>, and adds children to it. The content area defaults to MachineUIPanel.DEFAULT_CONTENT_WIDTH × DEFAULT_CONTENT_HEIGHT (169 × 77) but will cover larger children.

@Override
public void buildMainUI(ParentWidget<?> mainWidget, PosGuiData guiData, PanelSyncManager syncManager,
                        UISettings settings) {
    IntSyncValue bucketSyncer = new IntSyncValue(() -> cache.getFluidInTank(0).getAmount(), v -> {});
    syncManager.syncValue("bucket_amount", bucketSyncer);

    mainWidget.child(new ParentWidget<>()
            .background(GTGuiTextures.DISPLAY)
            .size(90, 63)
            .center()
            .child(Text.lang("gtceu.gui.fluid_amount").asWidget()
                    .color(0xffffff)
                    .margin(8, 0, 8, 0))
            .child(Text.dynamic(() -> Component.literal(
                            FormattingUtil.formatBuckets(bucketSyncer.getIntValue())))
                    .asWidget()
                    .margin(8, 0, 20, 0)));
}

PumpMachine, FisherMachine and MinerMachine are good references for progressively more complicated content areas.

getPanelBuilder

Override getPanelBuilder when you need to change the panel frame itself. MachineUIPanelBuilder is a fluent builder with the following options:

Method Default Effect
attachInventory(boolean) true Attaches the player inventory below the content area.
addTitleBar(boolean) true Draws the machine name title bar.
drawGTLogo(boolean) false Draws a logo in the bottom right corner.
gtLogoTexture(UITexture) GTGuiTextures.GREGTECH_LOGO The logo texture to use when drawGTLogo is enabled.
addDefaultConfigurators(boolean) true Adds the power button, voiding button, distinct-buses button, batch mode button and recipe type button, when the machine supports them.
addTraitConfigurators(boolean) true Lets attached traits implementing IAttachConfiguratorsTrait add their own configurators.
leftConfigurators(Consumer<Flow>) no-op Adds your own buttons to the left configurator column.
rightConfigurators(Consumer<Flow>) no-op Adds your own buttons to the right configurator column.

A machine that wants an extra toggle button next to the stock ones:

@Override
public MachineUIPanelBuilder getPanelBuilder(PosGuiData data, PanelSyncManager syncManager,
                                             UISettings settings) {
    return MachineUIPanelBuilder.panelBuilder(this)
            .rightConfigurators(configurators -> configurators
                    .child(new ToggleButton()
                            .value(new BoolValue.Dynamic(this::isJunkEnabled, this::setJunkEnabled))
                            .overlay(new ItemDrawable(Items.NAME_TAG))));
}

A machine that wants a bare panel with none of the stock buttons, as the primitive multiblocks do:

@Override
public MachineUIPanelBuilder getPanelBuilder(PosGuiData data, PanelSyncManager syncManager,
                                             UISettings settings) {
    return MachineUIPanelBuilder.panelBuilder(this)
            .addDefaultConfigurators(false)
            .addTraitConfigurators(false);
}

MachineUIPanelBuilder.defaultSteamMachinePanelBuilder(machine) is a shorthand for the above, and is what SteamBoilerMachine uses.

Note

The configurator columns are drawn to the left and right of the machine panel. They hide themselves when they have no children, so a machine that adds no configurators shows neither column.

shouldOpenUI

Return false to suppress opening the UI for a particular interaction. The default implementation always returns true. The block's onUse handling runs before this, so this hook only decides whether the panel opens, not whether the click is consumed.

@Override
public boolean shouldOpenUI(Player player, InteractionHand hand, BlockHitResult hit) {
    return isFormed();
}

Overriding buildUI directly

Override buildUI when you need more granular control outside what getPanelBuilder would allow you. One way to do this is to reproduce the default three steps, then keep working with the returned panel like so:

@Override
public ModularPanel<?> buildUI(PosGuiData data, PanelSyncManager syncManager, UISettings settings) {
    var panelBuilder = getPanelBuilder(data, syncManager, settings);
    panelBuilder.mainContents(parent -> buildMainUI(parent, data, syncManager, settings));
    var machinePanel = panelBuilder.build(syncManager, settings);

    // ... attach extra widgets and panels to or make further changes to machinePanel here

    return machinePanel;
}

If you skip getPanelBuilder/build entirely and construct a plain ModularPanel yourself, you lose the title bar, player inventory and configurators. Only do that for a machine whose UI has nothing in common with the standard machine layout.

Definition-level panels

A UI can also be attached to the machine definition rather than the machine class, with MachineBuilder::ui. It takes a PanelFactory, which is the same thing as IMuiMachine except the machine is handed to it as an argument instead of being this:

ModularPanel<?> buildUIFunction(PosGuiData data, PanelSyncManager syncManager, UISettings settings,
                                MetaMachine machine);
REGISTRATE.machine("my_machine", MyMachine::new)
        .ui((data, syncManager, settings, machine) -> MachineUIPanelBuilder.panelBuilder(machine)
                .mainContents(parent -> parent.child(Text.str("Hello").asWidget()))
                .build(syncManager, settings))
        .register();

This is how the shared singleblock recipe machine UI works: every simple machine gets GTSingleblockMachinePanels.GENERAL_MACHINE, a PanelFactory that reads the machine's recipe type UI layout and builds the slots from it. See Recipe Type UIs.

Warning

A definition-level UI takes precedence over IMuiMachine. If a machine has both, only the definition's PanelFactory runs, and buildUI/buildMainUI on the machine are never called.

  • tryToOpenUI(Player, InteractionHand, BlockHitResult) is called by MetaMachineBlock. It calls shouldOpenUI and then opens the panel through MachineUIFactory. You normally do not override this.
  • createScreen(PosGuiData, ModularPanel) is client-only, and returns the ModularScreen wrapper. It defaults to GTGuiScreen. Override it only if you need a custom screen implementation.

Multiblock display text

Multiblock controllers do not build their status readout in buildMainUI. WorkableElectricMultiblockMachine already overrides buildMainUI to draw the black display box in the middle of the panel, and fills it from getWidgetsForDisplay(PanelSyncManager), a flat List<IWidget> rendered one entry per line.

The column that holds them is set to collapseDisabledChildren, so a disabled line takes up no space. Give a widget a setEnabledIf to hide it without leaving a gap.

Adding lines from the definition

The usual way to add a line is additionalDisplay on the machine builder, which does not require a custom machine class. It is a BiFunction<MultiblockControllerMachine, PanelSyncManager, List<IWidget>>:

public static final MultiblockMachineDefinition MY_MULTIBLOCK = REGISTRATE
        .multiblock("my_multiblock", WorkableElectricMultiblockMachine::new)
        // ... rotation, recipe types, pattern, model ...
        .additionalDisplay((controller, syncManager) -> {
            if (!(controller instanceof WorkableElectricMultiblockMachine machine))
                return Collections.emptyList();

            BooleanSyncValue isFormed = syncManager.getOrCreateSyncHandler("isFormed",
                    BooleanSyncValue.class,
                    () -> new BooleanSyncValue(controller::isFormed));
            IntSyncValue tier = syncManager.getOrCreateSyncHandler("machineTier", IntSyncValue.class,
                    () -> new IntSyncValue(machine::getTier));

            return List.of(Text
                    .dynamic(() -> Component.translatable("my_addon.multiblock.my_multiblock.tier",
                            GTValues.VNF[tier.getIntValue()]))
                    .asWidget()
                    .setEnabledIf(w -> isFormed.getBoolValue()));
        })
        .register();

Most real uses need the same things this example does:

  • The function is handed a MultiblockControllerMachine, which has no tier, no recipe logic and no coils. Anything more specific needs an instanceof check and an emptyList() bail-out. getTier() above comes from ITieredMachine, not from the base class.
  • getWidgetsForDisplay runs on both the client and the server, and the controller's fields are only meaningful on the server, so anything the text depends on goes through a sync handler. Use getOrCreateSyncHandler to reuse a handler if another line already made one under that name.
  • Text.lang and Text.str are evaluated once when the panel is built, while Text.dynamic re-evaluates while it is open. Use Text.dynamic for text that changes.
  • Most status text is meaningless for an unformed structure, so guard it with setEnabledIf.

Adding the same line from the machine class

If the multiblock already has its own class, override getWidgetsForDisplay instead. The same line as above becomes:

public class MyMultiblockMachine extends WorkableElectricMultiblockMachine {

    @Override
    public List<IWidget> getWidgetsForDisplay(PanelSyncManager syncManager) {
        List<IWidget> widgets = super.getWidgetsForDisplay(syncManager);

        BooleanSyncValue isFormed = syncManager.getOrCreateSyncHandler("isFormed",
                BooleanSyncValue.class,
                () -> new BooleanSyncValue(this::isFormed));
        IntSyncValue tier = syncManager.getOrCreateSyncHandler("machineTier", IntSyncValue.class,
                () -> new IntSyncValue(this::getTier));

        widgets.add(Text
                .dynamic(() -> Component.translatable("my_addon.multiblock.my_multiblock.tier",
                        GTValues.VNF[tier.getIntValue()]))
                .asWidget()
                .setEnabledIf(w -> isFormed.getBoolValue()));

        return widgets;
    }
}

The syncing, Text.dynamic and setEnabledIf points above apply here too. The main difference is that you call super.getWidgetsForDisplay first and add to what it returns. Dropping the super call replaces the entire readout instead of adding to it. Because you own the list here, you can also insert a line at a specific position or remove a default one.

Where the lines end up

Lines added through additionalDisplay are inserted part-way down the standard list rather than appended to the end of it, below the machine's core status lines and above the parallel and output ones. The exact position is decided by WorkableMultiblockMachine.getWidgetsForDisplay.