diff --git a/docs/gui-screens.md b/docs/gui-screens.md
index 8e27ab39..2e3fca9e 100644
--- a/docs/gui-screens.md
+++ b/docs/gui-screens.md
@@ -132,6 +132,211 @@ under `build/gui-smoke*`, checks server actions and full codecs, opens the actua
synced values, and saves `screenshots/gui-smoke.png`. Run a normal build afterward to remove
injected smoke classes from main output; release verification rejects any leaked smoke classes.
+## Registering custom widget types
+
+A consuming mod can add a leaf widget without replacing MMDLib's screen or menu. Register its
+common-side contract in the mod constructor, before screen discovery:
+
+```java
+import java.util.Set;
+import net.minecraft.resources.ResourceLocation;
+import zone.moddev.mc.lib.gui.WidgetType;
+import zone.moddev.mc.lib.gui.WidgetTypes;
+import zone.moddev.mc.lib.gui.load.BoundValue.Kind;
+
+WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:temperature"),
+ new WidgetType(Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER), widget -> {
+ if (widget.common().value().isEmpty() || widget.common().max().isEmpty())
+ throw new IllegalArgumentException("Temperature needs value and max");
+ if (!widget.properties().isEmpty())
+ throw new IllegalArgumentException("Temperature has no custom properties");
+ }));
+```
+
+`valueKinds` lists the declared getter return kinds the widget accepts. Use an empty set for a
+decoration without a value; `max`, when present, is always numeric and requires `value`. The
+validator checks required bindings, dimensions and properties. Throw `IllegalArgumentException`
+with an author-facing explanation; MMDLib adds the screen file, line and widget type. Validators
+run on both sides at startup and must not use client classes or a live world. IDs cannot be
+replaced, and type registration closes when startup screen discovery begins.
+
+Use the registered type in any ordinary widget container:
+
+```xml
+
+```
+
+Width and height are required, from 1 to 1024 GUI pixels; the existing `s` slot unit is supported.
+The element receives normal box placement and supports all common attributes, including offsets,
+tooltips, `on-click`, `on-right-click`, shifted clicks, `on-hover`, `hover-shows`, and `jei-recipes`.
+An interactive instance handles input first. If it returns `PASS`, JEI recipe actions take
+priority over ordinary click handlers; use a separate recipes control when needed.
+
+Types that need configuration can accept named properties:
+
+```xml
+
+
+
+```
+
+The type validator owns the allowed property names and value formats. Duplicate names fail
+loading. `widget.properties()` is an immutable, sorted map of strings, included in the screen
+fingerprint. Property nodes do not create menu elements or slots. Unknown XML attributes still
+fail schema validation. The generic XSD can describe property structure; per-type rules are
+checked by Java at startup.
+
+Register drawing separately, **only on the client**, during client mod construction or
+`FMLClientSetupEvent`:
+
+```java
+import zone.moddev.mc.lib.gui.client.WidgetRenderers;
+
+WidgetRenderers.GLOBAL.register(ResourceLocation.parse("example:temperature"), context -> {
+ var g = context.graphics();
+ var r = context.bounds();
+ g.fill(r.x(), r.y(), r.right(), r.bottom(), 0xff353535);
+ int filled = (int) Math.round(r.height() * context.ratio());
+ g.fill(r.x(), r.bottom() - filled, r.right(), r.bottom(), 0xffdd6644);
+});
+```
+
+The context supplies the custom element, absolute GUI bounds and mouse coordinates, partial
+tick, and the server-synced `value()` / `maximum()`. Values can be null before sync or when a
+getter fails. Whole numbers arrive as `BigInteger`, decimals as `Double`; boolean, fluid and
+text kinds use the existing protocol. `ratio()` clamps numeric values to 0..1 and returns zero
+when unavailable. `hovered()` tests the widget's rectangle. Do not mutate or retain synced
+objects, and never call machine getters from the renderer.
+
+MMDLib clips drawing to the widget rectangle and restores its pose and tint afterward. Renderers
+must balance any additional pose/scissor pushes and restore any other render state they change.
+Renderer IDs must have a common-side type, and every custom widget used in a loaded screen must
+have a renderer. These checks run at client load completion, when renderer registration closes.
+Rendering and this registry are never loaded on a dedicated server.
+
+This API supports leaf widgets. It does not provide custom child containers or additional getter
+channels beyond `value` and `max`. Stateful input and typed actions are described below. Ordinary
+click handlers continue to use menu ownership, distance, visibility and action validation.
+
+The companion Crusher demonstrates a clickable progress dial in
+`CompanionWidgets`, `client.CompanionWidgetsClient`, and `mmdlib/screen/crusher.xml`. Its custom
+click pauses/resumes the machine through the same server callback as the pause button. The
+companion game tests exercise it on a dedicated server, and the client smoke checks its synced
+values and click path. For tools that own loading, `ScreenReader.read` and `ScreenBinder.bind`
+also accept an explicit `WidgetTypes` registry; use the same registry for both.
+
+### Interactive instances
+
+Use `WidgetRenderers.GLOBAL.registerInteractive(typeId, MyWidget::new)` to register a
+`WidgetFactory`. It receives a `WidgetAccess` and returns a **fresh `WidgetInstance`** per element
+per screen. Keep draft text, cursor/selection, drag previews and other local state in that instance.
+Do not keep editing state in a static renderer. The existing `register` method remains available
+for stateless drawing.
+
+`WidgetAccess` exposes the element, live absolute bounds, server-confirmed value and maximum,
+`pending()`, and `submit(action, payload)`. `WidgetInstance` implements rendering plus optional
+mouse, scroll, key, character, tick, focus and lifecycle methods. Its click result is:
+
+| Result | Meaning |
+|---|---|
+| `PASS` | Try the element's ordinary recipe/click behavior and underlying screen handling |
+| `CONSUME` | Handle this click; focus the instance if `focusable()` is true |
+| `CAPTURE` | Also receive dragging and release for this mouse button outside the widget bounds |
+
+Only a captured press receives mouse drag/release callbacks. Releasing an inventory drag over a
+widget leaves the release with the inventory, regardless of which widget is under the pointer.
+
+Tab and Shift+Tab move between visible focusable custom widgets in document order. While one is
+focused, inventory shortcuts are consumed even when its `keyPressed` returns false, so typing E,
+Q or a number cannot close the screen or move a slot. `charTyped` is separate from `keyPressed`.
+Escape first cancels capture; otherwise it is offered to the focused instance. If unhandled, it
+clears focus. With no focused instance, the screen's normal back/close behavior applies.
+
+Hiding a widget cancels capture, removes focus and calls `visibilityChanged(false)`. A cancelled
+drag does not receive a committing release, and that release cannot leak into an inventory slot.
+Resize calls `cancelInteraction()` on a captured instance and `resized()` on all instances; the
+instances and keyboard focus survive. Keep text drafts in `resized()` and use `visibilityChanged`
+to decide whether to discard drafts on a view change. `removed()` runs once per instance when
+the screen is removed. Balance any widget-owned resources there.
+If another screen (such as a recipe browser) returns to the same `MachineScreen` object, fresh
+widget instances are created on reinitialization. Ordinary resizing keeps the existing instances.
+
+The companion `NameEditor` adapts Minecraft's `EditBox`, including clipboard shortcuts, cursor
+movement, selection, drag selection, a 32-character limit, and Apply/Cancel. It retains a dirty
+draft while server values change, and only replaces it on successful submission or explicit
+cancellation. `ReserveSlider` previews while dragging and sends one update on release; it also
+supports scroll, arrow keys, Shift for ten-point steps, Home and End.
+
+### Typed actions and acknowledgments
+
+Define an action on the common side and add it to the third argument of `WidgetType`. Only actions
+in that type's list can be invoked by its elements. A handler receives the authenticated server
+player, menu and custom element; return false to reject. Check permissions, numeric bounds,
+allowed options and any element-specific property **before** mutating the machine.
+
+```java
+var setLimit = new WidgetAction(ResourceLocation.parse("example:set_limit"),
+ StreamCodec.of((buffer, value) -> buffer.writeVarInt(value), buffer -> buffer.readVarInt()),
+ 5, (context, value) -> {
+ if (value < 0 || value > 100) return false;
+ if (!(context.menu().blockEntity instanceof PressBlockEntity press)) return false;
+ press.setLimit(value);
+ return true;
+ });
+
+WidgetTypes.GLOBAL.register(ResourceLocation.parse("example:limit_slider"),
+ new WidgetType(Set.of(Kind.WHOLE), widget -> {
+ if (widget.common().value().isEmpty())
+ throw new IllegalArgumentException("Limit slider needs value");
+ }, List.of(setLimit)));
+```
+
+The relevant imports are `gui.WidgetAction`, `gui.WidgetType`, `gui.WidgetTypes`,
+`gui.load.BoundValue.Kind`, `net.minecraft.network.codec.StreamCodec`, and the ordinary Java
+collections. Codecs use `RegistryFriendlyByteBuf`, so structured records and registry-aware
+values are supported. Register matching action IDs and codecs on both sides. Each action declares
+a maximum encoded size of 1–4096 bytes. The envelope is bounded before the mod codec runs, the
+encoder uses a bounded buffer, and trailing bytes are refused. **Codecs must additionally bound
+their own string and collection lengths before allocating.**
+
+The client instance calls `access.submit(setLimit, previewValue)`. False means the widget is
+hidden, another request is pending, an earlier result still needs delivery, or encoding failed.
+Codec runtime exceptions (including buffer overflow) are logged and return false without sending
+a packet or marking the widget pending. Do not submit every render frame. For sliders, commit on
+release; throttle continuous controls yourself.
+
+The server checks menu ownership, interaction distance, visibility, element type, allowed action,
+menu-session token and increasing request number before dispatch. At most eight typed actions
+per menu per server tick are processed; excess edits receive an immediate rejection using the last
+sent values, without invoking their codec, getters or handler. Replayed requests are dropped. Handler
+exceptions are logged and rejected, but MMDLib cannot undo a handler's partial mutations.
+
+The response includes accept/reject and fresh authoritative `value`/`max` snapshots after the
+handler (or the last sent values for rate-limited edits). They are applied before
+`WidgetInstance.actionResult(boolean)` runs and update the server's delta baseline. The next ordinary
+snapshot also refreshes those values, so a late acknowledgment ignored after timeout cannot leave
+the client stale. Ordinary snapshot sync continues for every viewer, including changes to other
+elements. Preserve a rejected text
+draft and show feedback so the player can correct it. Requests are limited to one in flight per
+widget. After 100 client ticks without a matching response, the control is unlocked and receives
+`actionResult(false)` to report an **unconfirmed** edit; that does not prove the server rolled back
+or never applied the edit. The timer follows the player's open menu even while another screen
+covers it; result callbacks are delivered when the machine screen resumes. Responses for old
+sessions or obsolete requests are ignored.
+
+The companion Settings view demonstrates all three cases: a named machine, an energy-reserve
+slider that holds processing below the chosen charge percentage, and a Run/Pause selector whose
+payload contains both lane index and state. The server restricts that selector to the lane named
+in its XML property. These settings persist with the machine. The graphical smoke run checks
+focus, typing, cancellation, resizing, view changes, release outside bounds and server rejection.
+
+The expanded menu protocol is version **2**, including a session token in menu-opening data.
+Client and server must use matching MMDLib builds.
+
## Files and IDs
Screens live in the same root `mmdlib/` folders as other definitions, under `screen/`. As with
diff --git a/docs/machine-companion.md b/docs/machine-companion.md
index 9ce72012..438bc211 100644
--- a/docs/machine-companion.md
+++ b/docs/machine-companion.md
@@ -37,9 +37,27 @@ inside to read longer text. All four screens place a vertical battery on the rig
reusable `` XML widget. Its red charge bars fill upwards inside a slim casing.
The energy and fluid gauges share matching edges and medium-grey empty backgrounds, darker than slots.
Energy bars are thin with a soft reflection; cylindrical glass shading covers only the fluid.
-The Crusher centers its input, progress arrow and output alongside it.
-Hover the energy display
-for stored and maximum FE; click the first progress arrow to see recipes when JEI is installed.
+The Crusher centers its input, a circular progress dial and output alongside it. The dial is a
+custom widget registered by the companion through MMDLib's [widget API](gui-screens.md#registering-custom-widget-types).
+Click the dial to pause or resume; its tooltip shows progress in ticks. The Crusher's Recipes
+button opens crushing recipes when JEI is installed. The other processors retain their progress
+arrows, which open recipes when clicked with JEI installed. Hover the energy display for stored
+and maximum FE.
+
+The Crusher's **Settings** button opens three examples of mod-defined interactive widgets:
+
+- **Machine name:** type up to 32 characters and choose Apply, or Cancel to restore the last
+ confirmed value. Empty names are rejected. The draft survives resizing; leaving Settings
+ discards an unapplied draft.
+- **Energy reserve:** drag to preview a percentage and release to save. The processor holds
+ while stored energy is below that percentage. Scroll or use arrow keys for one-point changes,
+ Shift+arrow for ten points, or Home/End for 0/100. The default is zero.
+- **Lane A:** choose Run or Pause. The action sends the lane and desired state together; the
+ server validates both.
+
+Tab cycles through the editors; Escape cancels an edit or focus before returning to the machine
+view. Name and reserve survive save/load. The companion's `client` package contains the input
+implementations, while `CompanionWidgets` owns their common-side validation and typed actions.
The Crusher also has a **4,000 mB (four-bucket) tank** on the left. Right-click the block with a
water or lava bucket to fill it, or an empty bucket to drain it; open the screen with an empty hand
diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
index c78ded40..9c798c46 100644
--- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
+++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
@@ -69,6 +69,7 @@ public final class CompanionMod {
}
public CompanionMod(IEventBus bus) {
+ CompanionWidgets.register();
BLOCKS.register(bus);
ITEMS.register(bus);
ENTITIES.register(bus);
diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java b/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java
new file mode 100644
index 00000000..c5527dbf
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/CompanionWidgets.java
@@ -0,0 +1,117 @@
+package zone.moddev.mc.companion;
+
+import java.util.Set;
+
+import net.minecraft.resources.ResourceLocation;
+
+import zone.moddev.mc.lib.gui.WidgetAction;
+import zone.moddev.mc.lib.gui.WidgetType;
+import zone.moddev.mc.lib.gui.WidgetTypes;
+import zone.moddev.mc.lib.gui.load.BoundValue.Kind;
+
+/** A consuming mod owns the widget contract; no client class is referenced here. */
+public final class CompanionWidgets {
+ public static final ResourceLocation PROGRESS_DIAL = CompanionMod.id("progress_dial");
+ public static final ResourceLocation NAME_EDITOR = CompanionMod.id("name_editor");
+ public static final ResourceLocation RESERVE_SLIDER = CompanionMod.id("reserve_slider");
+ public static final ResourceLocation LANE_SELECTOR = CompanionMod.id("lane_selector");
+
+ public static final WidgetAction RENAME =
+ new WidgetAction<>(
+ CompanionMod.id("rename"),
+ net.minecraft.network.codec.StreamCodec.of(
+ (b, value) -> b.writeUtf(value, 32), b -> b.readUtf(32)),
+ 132,
+ (context, value) ->
+ context.menu().blockEntity instanceof ProcessorEntity machine
+ && machine.rename(value));
+
+ public static final WidgetAction RESERVE =
+ new WidgetAction<>(
+ CompanionMod.id("reserve"),
+ net.minecraft.network.codec.StreamCodec.of(
+ (b, value) -> b.writeVarInt(value), b -> b.readVarInt()),
+ 5,
+ (context, value) ->
+ context.menu().blockEntity instanceof ProcessorEntity machine
+ && machine.setReservePercent(value));
+
+ public record LaneSetting(int lane, boolean paused) {}
+
+ public static final WidgetAction SET_LANE =
+ new WidgetAction<>(
+ CompanionMod.id("set_lane"),
+ net.minecraft.network.codec.StreamCodec.of(
+ (b, value) -> {
+ b.writeVarInt(value.lane());
+ b.writeBoolean(value.paused());
+ },
+ b -> new LaneSetting(b.readVarInt(), b.readBoolean())),
+ 6,
+ (context, value) -> {
+ if (!(context.menu().blockEntity instanceof ProcessorEntity machine)
+ || value.lane() != Integer.parseInt(context.widget().properties().get("lane"))
+ || value.lane() < 0
+ || value.lane() >= machine.lanes.size()) return false;
+ if (machine.lanes.get(value.lane()).paused() != value.paused())
+ machine.toggle(value.lane());
+ return true;
+ });
+
+ private CompanionWidgets() {}
+
+ public static void register() {
+ WidgetTypes.GLOBAL.register(
+ NAME_EDITOR,
+ new WidgetType(
+ Set.of(Kind.TEXT),
+ widget -> validateEditor(widget, 140, 42, Set.of()),
+ java.util.List.of(RENAME)));
+ WidgetTypes.GLOBAL.register(
+ RESERVE_SLIDER,
+ new WidgetType(
+ Set.of(Kind.WHOLE),
+ widget -> validateEditor(widget, 100, 30, Set.of()),
+ java.util.List.of(RESERVE)));
+ WidgetTypes.GLOBAL.register(
+ LANE_SELECTOR,
+ new WidgetType(
+ Set.of(Kind.BOOLEAN),
+ widget -> {
+ validateEditor(widget, 100, 30, Set.of("lane"));
+ String lane = widget.properties().get("lane");
+ if (!"0".equals(lane) && !"1".equals(lane))
+ throw new IllegalArgumentException("lane must be 0 or 1");
+ },
+ java.util.List.of(SET_LANE)));
+ WidgetTypes.GLOBAL.register(
+ PROGRESS_DIAL,
+ new WidgetType(
+ Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER),
+ widget -> {
+ if (widget.common().value().isEmpty() || widget.common().max().isEmpty())
+ throw new IllegalArgumentException("A progress dial needs value and max getters");
+ if (widget.width() < 16 || widget.height() < 16)
+ throw new IllegalArgumentException("A progress dial needs at least 16 x 16 px");
+ for (var name : widget.properties().keySet())
+ if (!name.equals("color"))
+ throw new IllegalArgumentException("Unknown property: " + name);
+ String color = widget.properties().getOrDefault("color", "#55CC88");
+ if (!color.matches("#[0-9a-fA-F]{6}"))
+ throw new IllegalArgumentException("color must be #RRGGBB");
+ }));
+ }
+
+ private static void validateEditor(
+ zone.moddev.mc.lib.gui.model.Element.Custom widget,
+ int width,
+ int height,
+ Set properties) {
+ if (widget.common().value().isEmpty())
+ throw new IllegalArgumentException("Editor needs a value getter");
+ if (widget.width() < width || widget.height() < height)
+ throw new IllegalArgumentException("Editor needs at least " + width + " x " + height + " px");
+ if (!properties.containsAll(widget.properties().keySet()))
+ throw new IllegalArgumentException("Unknown editor property");
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
index cff95ce2..4d18816d 100644
--- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
+++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
@@ -48,6 +48,31 @@ public enum Kind {
private final Kind kind;
// 0 ignores redstone, 1 requires a signal, 2 requires no signal. Manual pause is separate.
private int redstoneMode;
+ private String machineName = "Crusher";
+ private int reservePercent;
+
+ public Component getMachineName() {
+ return Component.literal(machineName);
+ }
+
+ public int getReservePercent() {
+ return reservePercent;
+ }
+
+ public boolean rename(String name) {
+ if (name.isBlank() || name.length() > 32 || name.codePoints().anyMatch(Character::isISOControl))
+ return false;
+ machineName = name.strip();
+ setChanged();
+ return true;
+ }
+
+ public boolean setReservePercent(int value) {
+ if (value < 0 || value > 100) return false;
+ reservePercent = value;
+ setChanged();
+ return true;
+ }
public ProcessorEntity(BlockEntityType> type, BlockPos pos, BlockState state, Kind kind) {
super(type, pos, state);
@@ -78,7 +103,8 @@ public ProcessorEntity(BlockEntityType> type, BlockPos pos, BlockState state,
@Override
public void serverTick() {
- if (redstoneAllowsRun()) features.tick(level);
+ if (redstoneAllowsRun() && energy.stored() >= energy.capacity() * reservePercent / 100)
+ features.tick(level);
}
public int getRedstoneMode() {
@@ -143,6 +169,9 @@ public boolean getSecondPaused() {
public List getWarnings() {
var warnings = new ArrayList();
+ if (energy.stored() < energy.capacity() * reservePercent / 100)
+ warnings.add(
+ Component.translatable("screen.mmdlib_companion.reserve.waiting", reservePercent));
if (!redstoneAllowsRun())
warnings.add(Component.translatable("screen.mmdlib_companion.redstone.waiting"));
for (int i = 0; i < lanes.size(); i++) {
@@ -189,6 +218,8 @@ protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries)
super.saveAdditional(tag, registries);
tag.put("items", items.serializeNBT(registries));
tag.putInt("redstone_mode", redstoneMode);
+ tag.putString("machine_name", machineName);
+ tag.putInt("reserve_percent", reservePercent);
if (hasTank()) tag.put("fluids", fluids.serializeNBT(registries));
tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE)));
}
@@ -199,6 +230,14 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries)
super.loadAdditional(tag, registries);
int savedMode = tag.getInt("redstone_mode");
redstoneMode = savedMode >= 0 && savedMode <= 2 ? savedMode : 0;
+ String savedName = tag.getString("machine_name");
+ machineName =
+ !savedName.isBlank()
+ && savedName.length() <= 32
+ && savedName.codePoints().noneMatch(Character::isISOControl)
+ ? savedName.strip()
+ : "Crusher";
+ reservePercent = Math.clamp(tag.getInt("reserve_percent"), 0, 100);
items.deserializeNBT(registries, tag.getCompound("items"));
if (hasTank() && tag.contains("fluids")) fluids.deserializeNBT(registries, tag.getCompound("fluids"));
features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features"));
diff --git a/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java
new file mode 100644
index 00000000..0212392c
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java
@@ -0,0 +1,45 @@
+package zone.moddev.mc.companion.client;
+
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
+
+import zone.moddev.mc.companion.CompanionMod;
+import zone.moddev.mc.companion.CompanionWidgets;
+import zone.moddev.mc.lib.gui.client.WidgetRenderer;
+import zone.moddev.mc.lib.gui.client.WidgetRenderers;
+
+/** Client-only registration and drawing, using only MMDLib's public widget API. */
+@EventBusSubscriber(modid = CompanionMod.ID, bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
+public final class CompanionWidgetsClient {
+ private CompanionWidgetsClient() {}
+
+ @SubscribeEvent
+ public static void setup(FMLClientSetupEvent event) {
+ WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.NAME_EDITOR, NameEditor::new);
+ WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.RESERVE_SLIDER, ReserveSlider::new);
+ WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.LANE_SELECTOR, LaneSelector::new);
+ WidgetRenderers.GLOBAL.register(
+ CompanionWidgets.PROGRESS_DIAL, CompanionWidgetsClient::drawDial);
+ }
+
+ private static void drawDial(WidgetRenderer.Context context) {
+ var g = context.graphics();
+ var r = context.bounds();
+ int color =
+ 0xff000000
+ | Integer.parseInt(
+ context.widget().properties().getOrDefault("color", "#55CC88").substring(1), 16);
+ int cx = r.x() + r.width() / 2, cy = r.y() + r.height() / 2;
+ double radius = Math.min(r.width(), r.height()) / 2.0 - 2;
+ int lit = (int) Math.round(context.ratio() * 24);
+ for (int i = 0; i < 24; i++) {
+ double angle = i * Math.PI * 2 / 24 - Math.PI / 2;
+ int x = cx + (int) Math.round(Math.cos(angle) * radius);
+ int y = cy + (int) Math.round(Math.sin(angle) * radius);
+ g.fill(x - 1, y - 1, x + 1, y + 1, i < lit ? color : 0xff454545);
+ }
+ g.fill(cx - 1, cy - 1, cx + 1, cy + 1, context.hovered() ? 0xffffffff : color);
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java b/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java
new file mode 100644
index 00000000..292dbca4
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/client/LaneSelector.java
@@ -0,0 +1,87 @@
+package zone.moddev.mc.companion.client;
+
+import net.minecraft.client.Minecraft;
+
+import zone.moddev.mc.companion.CompanionWidgets;
+import zone.moddev.mc.lib.gui.client.WidgetAccess;
+import zone.moddev.mc.lib.gui.client.WidgetInstance;
+import zone.moddev.mc.lib.gui.layout.Rect;
+
+/** Sends a structured lane-and-state action, validated against this element's configured lane. */
+final class LaneSelector implements WidgetInstance {
+ private final WidgetAccess access;
+ private boolean focused, unconfirmed;
+
+ LaneSelector(WidgetAccess access) {
+ this.access = access;
+ }
+
+ private Rect option(boolean paused) {
+ var r = access.bounds();
+ int w = (r.width() - 4) / 2;
+ return new Rect(r.x() + (paused ? w + 4 : 0), r.y() + 12, w, 18);
+ }
+
+ private void choose(boolean paused) {
+ if (!access.pending())
+ access.submit(
+ CompanionWidgets.SET_LANE,
+ new CompanionWidgets.LaneSetting(
+ Integer.parseInt(access.element().properties().get("lane")), paused));
+ }
+
+ @Override
+ public void render(Context context) {
+ var r = context.bounds();
+ context
+ .graphics()
+ .drawString(
+ Minecraft.getInstance().font,
+ WidgetDrawing.text(unconfirmed ? "unconfirmed" : "lane_state"),
+ r.x(),
+ r.y(),
+ 0xff404040,
+ false);
+ boolean paused = Boolean.TRUE.equals(access.value());
+ WidgetDrawing.button(context.graphics(), option(false), "run", !paused, !access.pending());
+ WidgetDrawing.button(context.graphics(), option(true), "pause", paused, !access.pending());
+ if (focused) context.graphics().fill(r.x(), r.bottom() - 1, r.right(), r.bottom(), 0xfff5d477);
+ }
+
+ @Override
+ public boolean focusable() {
+ return true;
+ }
+
+ @Override
+ public void setFocused(boolean value) {
+ focused = value;
+ }
+
+ @Override
+ public Click mouseClicked(double x, double y, int button) {
+ if (button == 0) {
+ if (option(false).contains(x, y)) choose(false);
+ else if (option(true).contains(x, y)) choose(true);
+ }
+ return Click.CONSUME;
+ }
+
+ @Override
+ public boolean keyPressed(int key, int scan, int modifiers) {
+ if (key == 263 || key == 262) {
+ choose(key == 262);
+ return true;
+ }
+ if (key == 32 || key == 257) {
+ choose(!Boolean.TRUE.equals(access.value()));
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void actionResult(boolean accepted) {
+ unconfirmed = !accepted;
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java b/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java
new file mode 100644
index 00000000..8fcd25dd
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/client/NameEditor.java
@@ -0,0 +1,176 @@
+package zone.moddev.mc.companion.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.network.chat.Component;
+
+import zone.moddev.mc.companion.CompanionWidgets;
+import zone.moddev.mc.lib.gui.client.WidgetAccess;
+import zone.moddev.mc.lib.gui.client.WidgetInstance;
+import zone.moddev.mc.lib.gui.layout.Rect;
+
+/** Vanilla text editing supplies selection, cursor movement and clipboard shortcuts. */
+final class NameEditor implements WidgetInstance {
+ private final WidgetAccess access;
+ private final EditBox field;
+ private boolean dirty, syncing;
+ private String status = "";
+ private int selectionAnchor;
+ private boolean selecting;
+
+ NameEditor(WidgetAccess access) {
+ this.access = access;
+ field =
+ new EditBox(
+ Minecraft.getInstance().font, access.bounds().width(), 18, WidgetDrawing.text("name"));
+ field.setMaxLength(32);
+ field.setResponder(
+ value -> {
+ if (!syncing) {
+ dirty = true;
+ status = "";
+ }
+ });
+ }
+
+ private void position() {
+ var r = access.bounds();
+ field.setX(r.x());
+ field.setY(r.y());
+ field.setWidth(r.width());
+ }
+
+ private Rect applyBounds() {
+ var r = access.bounds();
+ return new Rect(r.x(), r.y() + 22, 44, 18);
+ }
+
+ private Rect cancelBounds() {
+ var r = access.bounds();
+ return new Rect(r.x() + 48, r.y() + 22, 44, 18);
+ }
+
+ private void reset() {
+ if (access.value() instanceof Component value) {
+ syncing = true;
+ if (!field.getValue().equals(value.getString())) field.setValue(value.getString());
+ syncing = false;
+ dirty = false;
+ }
+ status = "";
+ }
+
+ @Override
+ public void render(Context context) {
+ position();
+ if (!dirty && !access.pending()) reset();
+ field.setEditable(!access.pending());
+ field.render(context.graphics(), context.mouseX(), context.mouseY(), context.partialTick());
+ WidgetDrawing.button(context.graphics(), applyBounds(), "apply", false, !access.pending());
+ WidgetDrawing.button(context.graphics(), cancelBounds(), "cancel", false, !access.pending());
+ String message = access.pending() ? "waiting" : status;
+ if (!message.isEmpty())
+ context
+ .graphics()
+ .drawString(
+ Minecraft.getInstance().font,
+ WidgetDrawing.text(message),
+ access.bounds().x() + 96,
+ access.bounds().y() + 27,
+ 0xff883333,
+ false);
+ }
+
+ private void apply() {
+ if (access.pending()) return;
+ if (field.getValue().isBlank()) {
+ status = "name_required";
+ return;
+ }
+ access.submit(CompanionWidgets.RENAME, field.getValue());
+ }
+
+ @Override
+ public boolean focusable() {
+ return true;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ field.setFocused(focused);
+ }
+
+ @Override
+ public Click mouseClicked(double x, double y, int button) {
+ position();
+ if (button != 0) return Click.CONSUME;
+ if (applyBounds().contains(x, y)) apply();
+ else if (cancelBounds().contains(x, y)) {
+ if (!access.pending()) reset();
+ } else if (!access.pending() && field.mouseClicked(x, y, button)) {
+ selectionAnchor = field.getCursorPosition();
+ selecting = true;
+ return Click.CAPTURE;
+ }
+ return Click.CONSUME;
+ }
+
+ @Override
+ public boolean keyPressed(int key, int scan, int modifiers) {
+ if (key == 256) {
+ if (!dirty || access.pending()) return false;
+ reset();
+ return true;
+ }
+ if (key == 257 || key == 335) {
+ apply();
+ return true;
+ }
+ return !access.pending() && field.keyPressed(key, scan, modifiers);
+ }
+
+ @Override
+ public boolean charTyped(char character, int modifiers) {
+ return !access.pending() && field.charTyped(character, modifiers);
+ }
+
+ @Override
+ public void resized() {
+ position();
+ }
+
+ @Override
+ public boolean mouseDragged(double x, double y, int button, double dx, double dy) {
+ if (selecting) {
+ field.onClick(x, y);
+ field.setHighlightPos(selectionAnchor);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean mouseReleased(double x, double y, int button) {
+ boolean handled = selecting;
+ selecting = false;
+ return handled;
+ }
+
+ @Override
+ public void cancelInteraction() {
+ selecting = false;
+ }
+
+ @Override
+ public void visibilityChanged(boolean visible) {
+ if (!visible) reset();
+ }
+
+ @Override
+ public void actionResult(boolean accepted) {
+ if (accepted) reset();
+ else {
+ dirty = true;
+ status = "unconfirmed";
+ }
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java b/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java
new file mode 100644
index 00000000..8d00374c
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/client/ReserveSlider.java
@@ -0,0 +1,119 @@
+package zone.moddev.mc.companion.client;
+
+import net.minecraft.client.Minecraft;
+
+import zone.moddev.mc.companion.CompanionWidgets;
+import zone.moddev.mc.lib.gui.client.WidgetAccess;
+import zone.moddev.mc.lib.gui.client.WidgetInstance;
+
+/** Drag previews stay local; only release commits. Arrow keys and scroll commit one step. */
+final class ReserveSlider implements WidgetInstance {
+ private final WidgetAccess access;
+ private int preview;
+ private boolean dragging, focused, unconfirmed;
+
+ ReserveSlider(WidgetAccess access) {
+ this.access = access;
+ }
+
+ private int confirmed() {
+ return access.value() instanceof Number value ? value.intValue() : 0;
+ }
+
+ private void at(double x) {
+ var r = access.bounds();
+ preview = (int) Math.clamp(Math.round((x - r.x() - 3) * 100 / (r.width() - 6)), 0, 100);
+ }
+
+ private void commit() {
+ access.submit(CompanionWidgets.RESERVE, preview);
+ }
+
+ @Override
+ public void render(Context context) {
+ if (!dragging && !access.pending()) preview = confirmed();
+ var r = context.bounds();
+ var g = context.graphics();
+ g.drawString(
+ Minecraft.getInstance().font,
+ WidgetDrawing.text(unconfirmed ? "reserve.unconfirmed" : "reserve", preview),
+ r.x(),
+ r.y(),
+ 0xff404040,
+ false);
+ g.fill(r.x(), r.y() + 15, r.right(), r.y() + 28, focused ? 0xfff5d477 : 0xff444444);
+ g.fill(r.x() + 1, r.y() + 16, r.right() - 1, r.y() + 27, 0xff727272);
+ int knob = r.x() + 3 + (r.width() - 6) * preview / 100;
+ g.fill(knob - 2, r.y() + 16, knob + 2, r.y() + 27, access.pending() ? 0xffaaaaaa : 0xff66cc88);
+ }
+
+ @Override
+ public boolean focusable() {
+ return true;
+ }
+
+ @Override
+ public void setFocused(boolean value) {
+ focused = value;
+ }
+
+ @Override
+ public Click mouseClicked(double x, double y, int button) {
+ if (button != 0 || access.pending()) return Click.CONSUME;
+ unconfirmed = false;
+ dragging = true;
+ at(x);
+ return Click.CAPTURE;
+ }
+
+ @Override
+ public boolean mouseDragged(double x, double y, int button, double dx, double dy) {
+ at(x);
+ return true;
+ }
+
+ @Override
+ public boolean mouseReleased(double x, double y, int button) {
+ if (!dragging || button != 0) return false;
+ at(x);
+ dragging = false;
+ commit();
+ return true;
+ }
+
+ @Override
+ public void cancelInteraction() {
+ dragging = false;
+ preview = confirmed();
+ }
+
+ @Override
+ public boolean keyPressed(int key, int scan, int modifiers) {
+ if (key != 263 && key != 262 && key != 268 && key != 269) return false;
+ if (access.pending()) return true;
+ preview =
+ key == 268
+ ? 0
+ : key == 269
+ ? 100
+ : Math.clamp(
+ confirmed() + (key == 262 ? 1 : -1) * ((modifiers & 1) != 0 ? 10 : 1), 0, 100);
+ commit();
+ return true;
+ }
+
+ @Override
+ public boolean mouseScrolled(double x, double y, double dx, double dy) {
+ if (!access.pending() && dy != 0) {
+ preview = Math.clamp(confirmed() + (dy > 0 ? 1 : -1), 0, 100);
+ commit();
+ }
+ return true;
+ }
+
+ @Override
+ public void actionResult(boolean accepted) {
+ unconfirmed = !accepted;
+ preview = confirmed();
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java b/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java
new file mode 100644
index 00000000..a2b55ae2
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/client/WidgetDrawing.java
@@ -0,0 +1,29 @@
+package zone.moddev.mc.companion.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.network.chat.Component;
+
+import zone.moddev.mc.lib.gui.layout.Rect;
+
+final class WidgetDrawing {
+ private WidgetDrawing() {}
+
+ static Component text(String name, Object... arguments) {
+ return Component.translatable("screen.mmdlib_companion." + name, arguments);
+ }
+
+ static void button(GuiGraphics g, Rect r, String label, boolean selected, boolean enabled) {
+ g.fill(r.x(), r.y(), r.right(), r.bottom(), selected ? 0xff66bb88 : 0xff444444);
+ g.fill(r.x() + 1, r.y() + 1, r.right() - 1, r.bottom() - 1, enabled ? 0xff727272 : 0xff505050);
+ var font = Minecraft.getInstance().font;
+ var text = text(label);
+ g.drawString(
+ font,
+ text,
+ r.x() + (r.width() - font.width(text)) / 2,
+ r.y() + 5,
+ enabled ? 0xffffffff : 0xffaaaaaa,
+ false);
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
index d4077812..49849e3d 100644
--- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
+++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
@@ -27,6 +27,23 @@
"block.mmdlib_companion.dual_crusher": "Dual Crusher",
"block.mmdlib_companion.creative_power": "Creative Power Source",
"screen.mmdlib_companion.energy.tip": "{value} / {max} FE",
+ "screen.mmdlib_companion.settings": "Settings",
+ "screen.mmdlib_companion.settings.tip": "Edit the machine name, energy reserve and lane state.",
+ "screen.mmdlib_companion.name": "Machine name",
+ "screen.mmdlib_companion.apply": "Apply",
+ "screen.mmdlib_companion.cancel": "Cancel",
+ "screen.mmdlib_companion.waiting": "Waiting...",
+ "screen.mmdlib_companion.name_required": "Enter name",
+ "screen.mmdlib_companion.unconfirmed": "Retry edit",
+ "screen.mmdlib_companion.reserve": "Energy reserve: %s%%",
+ "screen.mmdlib_companion.reserve.unconfirmed": "Reserve: %s%% (retry)",
+ "screen.mmdlib_companion.reserve.waiting": "Energy below %s%% reserve: supply power or lower the reserve.",
+ "screen.mmdlib_companion.lane_state": "Lane A",
+ "screen.mmdlib_companion.run": "Run",
+ "screen.mmdlib_companion.pause": "Pause",
+ "screen.mmdlib_companion.progress_dial": "Progress: {value} / {max} ticks. Click to pause or resume.",
+ "screen.mmdlib_companion.recipes": "Recipes",
+ "screen.mmdlib_companion.recipes.tip": "Show crushing recipes in JEI, when installed.",
"screen.mmdlib_companion.tank.tip": "Water or lava: {value} / {max} mB. Click with a fluid container to transfer.",
"screen.mmdlib_companion.tank.refused": "Cannot transfer fluid. The tank holds water or lava, one type at a time, up to 4 buckets.",
"screen.mmdlib_companion.info.crusher": "*Crusher*\n\nCrush ore into raw materials, or stone into gravel and sand.\n\nPlace a power source beside the machine. Pause to release reserved items.\n\nThe left tank holds 4 buckets of water or lava for display. Click the gauge with a fluid container on the cursor to fill or drain it, or right-click the block with a bucket. Empty the tank before changing fluids. Crushing does not use the fluid.",
diff --git a/src/companion/resources/mmdlib/screen/crusher.xml b/src/companion/resources/mmdlib/screen/crusher.xml
index 3f7d7369..d535146e 100644
--- a/src/companion/resources/mmdlib/screen/crusher.xml
+++ b/src/companion/resources/mmdlib/screen/crusher.xml
@@ -1,18 +1,32 @@
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
index 62474e6f..fe36bb56 100644
--- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
@@ -19,10 +19,10 @@
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
-import net.neoforged.neoforge.fluids.FluidStack;
-import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RenderFrameEvent;
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction;
import zone.moddev.mc.companion.CompanionMod;
import zone.moddev.mc.companion.ProcessorEntity;
@@ -57,7 +57,12 @@ settings, new WorldOptions(1, false, false),
if (opening) {
if (++waitingTicks > 400)
throw new AssertionError("Menu " + stage + " did not render; screen=" + mc.screen
- + ", block entity=" + mc.level.getBlockEntity(POS));
+ + ", block entity=" + mc.level.getBlockEntity(POS)
+ + ", control stage=" + controlStage + ", fluid stage=" + fluidStage
+ + (mc.screen instanceof MachineScreen screen
+ ? ", cycle time=" + screen.getMenu().bound.screen().find("progress")
+ .map(element -> screen.getMenu().value(element, true)).orElse("missing")
+ : ""));
var entity = mc.level.getBlockEntity(POS);
if (!menuRequested && entity != null
&& entity.getType() == CompanionMod.MACHINES.get(stage).type.get()) {
@@ -119,32 +124,44 @@ public static void frame(RenderFrameEvent.Post event) {
var expected = CompanionMod.MACHINES.get(stage).type.get();
if (menu.blockEntity.getType() != expected || ++frames < 25) return;
try {
+ if (stage == 0 && !CompanionWidgetSmoke.run(screen)) {
+ frames = 0;
+ waitingTicks = 0;
+ return;
+ }
var energy = menu.bound.screen().find("energy").orElseThrow();
if (!(energy instanceof zone.moddev.mc.lib.gui.model.Element.EnergyGauge))
throw new AssertionError("Every companion screen must use the reusable battery gauge");
if (screen.layout.bounds(energy).right() != screen.layout.panel().width() - 7)
throw new AssertionError("Energy must sit against the right panel margin");
if (!(menu.value(energy, false) instanceof Number)) throw new AssertionError("Energy was not synced");
- if (stage == 0 && controlStage < 4) {
+ if (stage == 0 && controlStage < 5) {
+ var dial = menu.bound.screen().find("progress").orElseThrow();
+ if (!(dial instanceof zone.moddev.mc.lib.gui.model.Element.Custom)
+ || !(menu.value(dial, false) instanceof Number)
+ || !(menu.value(dial, true) instanceof Number))
+ throw new AssertionError("The mod-defined dial and its values must reach the client");
var pause = menu.bound.screen().find("toggle_0").orElseThrow();
var redstone = menu.bound.screen().find("redstone").orElseThrow();
if (!(menu.value(pause, false) instanceof Boolean paused)
|| !(menu.value(redstone, false) instanceof Number mode)) return;
if (controlStage == 0 && !paused && mode.intValue() == 0) {
- var bounds = screen.bounds(pause);
+ var bounds = screen.bounds(dial);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 1 && paused) {
Screenshot.grab(mc.gameDirectory, "crusher-paused.png", mc.getMainRenderTarget(), message -> {});
var bounds = screen.bounds(redstone);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 2 && mode.intValue() == 1) {
- var bounds = screen.bounds(pause);
+ var bounds = screen.bounds(dial);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 3 && !paused && mode.intValue() == 1) {
Screenshot.grab(mc.gameDirectory, "crusher-redstone.png", mc.getMainRenderTarget(), message -> {});
var bounds = screen.bounds(redstone);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
+ } else if (controlStage == 4 && !paused && mode.intValue() == 0) {
+ // Wait for the server to acknowledge the redstone reset before checking the cycle.
} else return;
controlStage++;
frames = 0;
@@ -153,7 +170,9 @@ public static void frame(RenderFrameEvent.Post event) {
if (stage < 3) {
var progress = menu.bound.screen().find("progress").orElseThrow();
if (!(menu.value(progress, true) instanceof Number n) || n.intValue() <= 0)
- throw new AssertionError("Cycle time was not synced");
+ // Resume reserves the cycle on a later server tick. An idle snapshot between cycles
+ // is also valid; render frames are not a barrier for server ticks or network updates.
+ return;
if (menu.getSlot(0).getItem().isEmpty()) throw new AssertionError("Input was not synced");
}
String name = menu.bound.screen().id().getPath();
@@ -238,6 +257,25 @@ public static void frame(RenderFrameEvent.Post event) {
Screenshot.grab(mc.gameDirectory, name + "-drawer.png", mc.getMainRenderTarget(), message -> {});
if (!screen.mouseClicked(tab.x() + 10, screen.exclusions().getFirst().bottom() - 8, 0))
throw new AssertionError("Clicks on the drawer rail must not pass through the screen");
+ // Vanilla returns true even for unused buttons. Observe its recorded press to verify
+ // these clicks reached inventory handling instead of being consumed by the drawer.
+ var lastButton =
+ net.minecraft.client.gui.screens.inventory.AbstractContainerScreen.class
+ .getDeclaredField("lastClickButton");
+ lastButton.setAccessible(true);
+ for (int button : new int[] {2, 3, 4})
+ for (var point :
+ new double[][] {
+ {tab.x() + 10, screen.exclusions().getFirst().bottom() - 8},
+ {tab.x() - 10, tab.y() + 10}
+ }) {
+ lastButton.setInt(screen, -1);
+ screen.mouseClicked(point[0], point[1], button);
+ if (lastButton.getInt(screen) != button)
+ throw new AssertionError("Drawer swallowed mouse button " + button);
+ if (screen.exclusions().size() != 2)
+ throw new AssertionError("Extra mouse button changed the drawer state");
+ }
screen.mouseScrolled(tab.x() - 10, tab.y() + 10, 0, -1);
screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0);
if (screen.exclusions().size() != 1)
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java
index bde2a472..f5a0adae 100644
--- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionGameTests.java
@@ -38,6 +38,280 @@
public final class CompanionGameTests {
private static final BlockPos CENTER = new BlockPos(2, 2, 2);
+ private static ScreenNetwork.Widget widgetPacket(
+ MachineMenu menu,
+ String element,
+ int request,
+ zone.moddev.mc.lib.gui.WidgetAction action,
+ T value) {
+ return new ScreenNetwork.Widget(
+ menu.containerId,
+ menu.widgetSession(),
+ menu.index(menu.bound.screen().find(element).orElseThrow()),
+ request,
+ action.id(),
+ action.encode(menu.blockEntity.getLevel().registryAccess(), value));
+ }
+
+ @GameTest(template = "empty")
+ public static void typedWidgetActionsAuthorizeBeforeMutation(GameTestHelper helper) {
+ var machine = place(helper, CompanionMod.CRUSHER);
+ var player = helper.makeMockServerPlayerInLevel();
+ var pos = machine.getBlockPos();
+ player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
+ var menu = new MachineMenu(51, player.getInventory(), machine);
+ player.containerMenu = menu;
+ var rename = zone.moddev.mc.companion.CompanionWidgets.RENAME;
+ try {
+ require(
+ !menu.widgetAction(player, widgetPacket(menu, "name_editor", 1, rename, "Hidden"))
+ .accepted(),
+ "Hidden widgets cannot mutate the machine");
+ menu.navigate("panel", "settings");
+ var packet = widgetPacket(menu, "name_editor", 2, rename, "Copper crusher");
+ require(
+ menu.widgetAction(
+ player,
+ new ScreenNetwork.Widget(
+ 51,
+ menu.widgetSession() + 1,
+ packet.element(),
+ 2,
+ rename.id(),
+ packet.data()))
+ == null,
+ "Old menu sessions are rejected");
+ var outsider = helper.makeMockServerPlayerInLevel();
+ require(menu.widgetAction(outsider, packet) == null, "Other players cannot use this menu");
+ var accepted = menu.widgetAction(player, packet);
+ require(
+ accepted.accepted()
+ && ((net.minecraft.network.chat.Component) accepted.value())
+ .getString()
+ .equals("Copper crusher"),
+ "Accept response carries the value after the handler, in the same tick");
+ require(menu.widgetAction(player, packet) == null, "Requests cannot be replayed");
+ var rejected = menu.widgetAction(player, widgetPacket(menu, "name_editor", 3, rename, " "));
+ require(
+ !rejected.accepted() && machine.getMachineName().getString().equals("Copper crusher"),
+ "Invalid text is rejected without changing the authoritative name");
+ var data = rename.encode(player.registryAccess(), "Malformed");
+ require(
+ !menu.widgetAction(
+ player,
+ new ScreenNetwork.Widget(
+ 51,
+ menu.widgetSession(),
+ packet.element(),
+ 4,
+ rename.id(),
+ java.util.Arrays.copyOf(data, data.length + 1)))
+ .accepted(),
+ "Trailing data is rejected");
+ require(
+ !menu.widgetAction(
+ player,
+ widgetPacket(
+ menu,
+ "name_editor",
+ 5,
+ zone.moddev.mc.companion.CompanionWidgets.RESERVE,
+ 50))
+ .accepted(),
+ "Actions are bound to widget types");
+ player.setPos(pos.getX() + 100, pos.getY(), pos.getZ());
+ require(
+ menu.widgetAction(player, widgetPacket(menu, "name_editor", 6, rename, "Too far"))
+ == null,
+ "Distance is checked before decoding");
+ var saved = machine.saveWithFullMetadata(helper.getLevel().registryAccess());
+ var copy =
+ (ProcessorEntity)
+ BlockEntity.loadStatic(
+ pos, machine.getBlockState(), saved, helper.getLevel().registryAccess());
+ require(copy.getMachineName().getString().equals("Copper crusher"), "Name persists");
+ } finally {
+ player.containerMenu = player.inventoryMenu;
+ helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
+ }
+ helper.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void structuredWidgetActionsBoundValuesRateAndPendingEdits(GameTestHelper helper) {
+ var machine = place(helper, CompanionMod.CRUSHER);
+ var player = helper.makeMockServerPlayerInLevel();
+ var pos = machine.getBlockPos();
+ player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
+ var menu = new MachineMenu(52, player.getInventory(), machine);
+ player.containerMenu = menu;
+ menu.navigate("panel", "settings");
+ var reserve = zone.moddev.mc.companion.CompanionWidgets.RESERVE;
+ var lane = zone.moddev.mc.companion.CompanionWidgets.SET_LANE;
+ try {
+ require(
+ !menu.widgetAction(player, widgetPacket(menu, "reserve_slider", 1, reserve, 101))
+ .accepted(),
+ "Reserve range checked");
+ require(
+ menu.widgetAction(player, widgetPacket(menu, "reserve_slider", 2, reserve, 25))
+ .accepted(),
+ "Valid reserve accepted");
+ require(
+ !menu.widgetAction(
+ player,
+ widgetPacket(
+ menu,
+ "lane_selector",
+ 3,
+ lane,
+ new zone.moddev.mc.companion.CompanionWidgets.LaneSetting(-1, true)))
+ .accepted(),
+ "Negative lane rejected");
+ require(
+ !menu.widgetAction(
+ player,
+ widgetPacket(
+ menu,
+ "lane_selector",
+ 4,
+ lane,
+ new zone.moddev.mc.companion.CompanionWidgets.LaneSetting(1, true)))
+ .accepted(),
+ "Unconfigured lane rejected");
+ require(
+ menu.widgetAction(
+ player,
+ widgetPacket(
+ menu,
+ "lane_selector",
+ 5,
+ lane,
+ new zone.moddev.mc.companion.CompanionWidgets.LaneSetting(0, true)))
+ .accepted()
+ && machine.getPaused(),
+ "Structured pause accepted");
+ require(
+ menu.widgetAction(
+ player,
+ widgetPacket(
+ menu,
+ "lane_selector",
+ 6,
+ lane,
+ new zone.moddev.mc.companion.CompanionWidgets.LaneSetting(0, false)))
+ .accepted()
+ && !machine.getPaused(),
+ "Structured resume accepted");
+ menu.widgetAction(player, widgetPacket(menu, "reserve_slider", 7, reserve, 25));
+ menu.widgetAction(player, widgetPacket(menu, "reserve_slider", 8, reserve, 25));
+ var excess = menu.widgetAction(player, widgetPacket(menu, "reserve_slider", 9, reserve, 26));
+ require(
+ excess != null && !excess.accepted() && ((Number) excess.value()).intValue() == 25,
+ "Excess edits are immediately rejected with the last sent value");
+ require(machine.getReservePercent() == 25, "Rate-limited edits do not run their handler");
+ var saved = machine.saveWithFullMetadata(helper.getLevel().registryAccess());
+ var copy =
+ (ProcessorEntity)
+ BlockEntity.loadStatic(
+ pos, machine.getBlockState(), saved, helper.getLevel().registryAccess());
+ require(copy.getReservePercent() == 25, "Reserve persists");
+ var name =
+ (zone.moddev.mc.lib.gui.model.Element.Custom)
+ menu.bound.screen().find("name_editor").orElseThrow();
+ require(
+ menu.prepareWidgetAction(
+ name, zone.moddev.mc.companion.CompanionWidgets.RENAME, "x".repeat(33))
+ .isEmpty(),
+ "Codec bounds failures are refused without escaping into the client");
+ require(
+ menu.prepareWidgetAction(name, zone.moddev.mc.companion.CompanionWidgets.RENAME, null)
+ .isEmpty(),
+ "Other codec runtime failures are also refused");
+ require(
+ !menu.widgetPending(name) && menu.takeWidgetResult(name) == null,
+ "Failed encoding leaves the widget ready for another edit");
+ var pending =
+ menu.prepareWidgetAction(
+ name, zone.moddev.mc.companion.CompanionWidgets.RENAME, "Pending")
+ .orElseThrow();
+ require(pending.request() == 1, "Failed encoding does not consume request numbers");
+ require(
+ menu.prepareWidgetAction(name, zone.moddev.mc.companion.CompanionWidgets.RENAME, "Second")
+ .isEmpty(),
+ "Only one in-flight edit per widget");
+ menu.receiveWidgetResult(
+ new ScreenNetwork.WidgetResult(
+ 52,
+ menu.widgetSession() + 1,
+ pending.element(),
+ pending.request(),
+ true,
+ net.minecraft.network.chat.Component.literal("Stale"),
+ null));
+ require(menu.widgetPending(name), "A stale response cannot settle a current edit");
+ for (int i = 0; i < 100; i++) menu.tickWidgetActions();
+ require(
+ !menu.widgetPending(name) && !menu.takeWidgetResult(name).accepted(),
+ "Timed-out edits unlock for retry");
+ } finally {
+ player.containerMenu = player.inventoryMenu;
+ helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
+ }
+ helper.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void widgetRejectionUnlocksImmediatelyAndAcknowledgmentsTrackDeltas(
+ GameTestHelper helper) {
+ var machine = place(helper, CompanionMod.CRUSHER);
+ var player = helper.makeMockServerPlayerInLevel();
+ var pos = machine.getBlockPos();
+ player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
+ var server = new MachineMenu(54, player.getInventory(), machine);
+ var client = new MachineMenu(54, player.getInventory(), machine, server.widgetSession());
+ player.containerMenu = server;
+ server.navigate("panel", "settings");
+ client.navigate("panel", "settings");
+ var name =
+ (zone.moddev.mc.lib.gui.model.Element.Custom)
+ server.bound.screen().find("name_editor").orElseThrow();
+ int key = server.index(name) * 2;
+ var rename = zone.moddev.mc.companion.CompanionWidgets.RENAME;
+ var original = machine.getMachineName();
+ server.apply(java.util.Map.of(key, original));
+ client.apply(java.util.Map.of(key, original));
+ try {
+ var packet = client.prepareWidgetAction(name, rename, "Temporary").orElseThrow();
+ var result = server.widgetAction(player, packet);
+ client.receiveWidgetResult(result);
+ require(client.takeWidgetResult(name).accepted(), "First edit accepted");
+ // The machine changes back before a regular broadcast has observed Temporary.
+ machine.rename(original.getString());
+ var delta =
+ zone.moddev.mc.lib.gui.runtime.ScreenValues.changed(
+ java.util.Map.of(key, server.wireValue(key)), java.util.Map.of(key, original), false);
+ require(delta.containsKey(key), "Reversion must be sent after an acknowledgment");
+ client.apply(delta);
+ server.apply(delta);
+ require(original.equals(client.value(name, false)), "Client converges to the reverted value");
+ for (int i = 2; i <= 9; i++) {
+ packet = client.prepareWidgetAction(name, rename, "Edit " + i).orElseThrow();
+ result = server.widgetAction(player, packet);
+ require(result != null && result.accepted() == (i <= 8), "Only eight edits may dispatch");
+ client.receiveWidgetResult(result);
+ require(!client.widgetPending(name), "Rejection unlocks without waiting for timeout");
+ client.takeWidgetResult(name);
+ }
+ require(machine.getMachineName().getString().equals("Edit 8"), "Ninth edit never mutates");
+ require(server.widgetAction(player, packet) == null, "Rejected requests cannot be replayed");
+ } finally {
+ player.containerMenu = player.inventoryMenu;
+ helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
+ }
+ helper.succeed();
+ }
+
@GameTest(template = "empty")
public static void clearedProcessorsRestartWithoutManualResume(GameTestHelper helper) {
for (var type : java.util.List.of(CompanionMod.CRUSHER, CompanionMod.TRANSMUTER, CompanionMod.DUAL_CRUSHER)) {
@@ -82,8 +356,42 @@ public static void registeredScreensAndRecipes(GameTestHelper helper) {
require(menu.bound.screen().id().equals(id), "Wrong menu for " + id);
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
}
- require(CompanionMod.CRUSHING.recipes(level.getRecipeManager()).size() == 5, "Five crusher recipes");
- require(CompanionMod.TRANSMUTING.recipes(level.getRecipeManager()).size() == 1, "One converter recipe");
+ require(
+ CompanionMod.CRUSHING.recipes(level.getRecipeManager()).size() == 5,
+ "Five crusher recipes");
+ require(
+ CompanionMod.TRANSMUTING.recipes(level.getRecipeManager()).size() == 1,
+ "One converter recipe");
+ helper.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void customWidgetUsesValidatedMenuActions(GameTestHelper helper) {
+ var machine = place(helper, CompanionMod.CRUSHER);
+ var player = helper.makeMockServerPlayerInLevel();
+ var pos = machine.getBlockPos();
+ player.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
+ var menu = new MachineMenu(47, player.getInventory(), machine);
+ player.containerMenu = menu;
+ try {
+ var dial = menu.bound.screen().find("progress").orElseThrow();
+ require(
+ dial instanceof zone.moddev.mc.lib.gui.model.Element.Custom,
+ "The consuming mod's widget loads on a dedicated server");
+ int index = menu.index(dial);
+ menu.action(player, new ScreenNetwork.Action(47, index, 0, 0, 0, 1));
+ require(!machine.getPaused(), "Custom widgets reject unrecognized action payloads");
+ menu.action(player, new ScreenNetwork.Action(48, index, 0, 0, 0, 0));
+ require(!machine.getPaused(), "A stale menu id cannot act on a custom widget");
+ menu.action(player, new ScreenNetwork.Action(47, index, 0, 0, 0, 0));
+ require(machine.getPaused(), "Custom widget click invokes the registered server handler");
+ menu.action(player, new ScreenNetwork.Action(47, index, 0, 0, 0, 0));
+ require(!machine.getPaused(), "A second custom widget click resumes the machine");
+ } finally {
+ // Mock players have no client connection to receive subsequent menu ticks.
+ player.containerMenu = player.inventoryMenu;
+ helper.getLevel().setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
+ }
helper.succeed();
}
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java
new file mode 100644
index 00000000..14e697df
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java
@@ -0,0 +1,205 @@
+package zone.moddev.mc.companion.test;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.Screenshot;
+import net.minecraft.network.chat.Component;
+
+import net.neoforged.neoforge.network.PacketDistributor;
+
+import zone.moddev.mc.companion.CompanionWidgets;
+import zone.moddev.mc.lib.gui.client.MachineScreen;
+import zone.moddev.mc.lib.gui.model.Element;
+
+/** Real client focus, text editing, captures, resize, navigation and acknowledged actions. */
+final class CompanionWidgetSmoke {
+ private static int step;
+
+ private CompanionWidgetSmoke() {}
+
+ private static void click(MachineScreen s, double x, double y) {
+ if (!s.mouseClicked(x, y, 0))
+ throw new AssertionError("Widget click not handled at step " + step);
+ s.mouseReleased(x, y, 0);
+ }
+
+ private static void type(MachineScreen s, String text) {
+ s.keyPressed(269, 0, 0);
+ for (int i = 0; i < 32; i++) s.keyPressed(259, 0, 0);
+ for (char c : text.toCharArray()) {
+ s.keyPressed(Character.toUpperCase(c), 0, 0);
+ if (!s.charTyped(c, 0)) throw new AssertionError("Text field lost focus at step " + step);
+ }
+ if (Minecraft.getInstance().screen != s)
+ throw new AssertionError("Inventory shortcut closed focused editor");
+ }
+
+ private static boolean nameIs(MachineScreen s, Element.Custom e, String name) {
+ return s.getMenu().value(e, false) instanceof Component c && c.getString().equals(name);
+ }
+
+ static boolean run(MachineScreen s) {
+ var mc = Minecraft.getInstance();
+ var menu = s.getMenu();
+ var name = (Element.Custom) menu.bound.screen().find("name_editor").orElseThrow();
+ var slider = (Element.Custom) menu.bound.screen().find("reserve_slider").orElseThrow();
+ var selector = (Element.Custom) menu.bound.screen().find("lane_selector").orElseThrow();
+ var n = s.bounds(name);
+ var r = s.bounds(slider);
+ var l = s.bounds(selector);
+ switch (step) {
+ case 0 -> {
+ var open = s.bounds(menu.bound.screen().find("open_settings").orElseThrow());
+ click(s, open.x() + 8, open.y() + 8);
+ }
+ case 1 -> {
+ if (!s.visible(name) || !(menu.value(name, false) instanceof Component)) return false;
+ // A recipe browser may remove the screen and later reinitialize the same object.
+ s.removed();
+ s.resize(mc, s.width, s.height);
+ click(s, n.x() + 8, n.y() + 8);
+ type(s, "draft e");
+ int w = s.width, h = s.height;
+ s.resize(mc, w + 20, h + 10);
+ s.resize(mc, w, h);
+ if (!s.charTyped('x', 0)) throw new AssertionError("Resize lost text focus");
+ click(s, n.x() + 20, n.y() + 28);
+ }
+ case 2 -> {
+ if (menu.widgetPending(name) || !nameIs(s, name, "draft ex")) return false;
+ // Bypass local validation to exercise a rejected server edit and its acknowledgment.
+ PacketDistributor.sendToServer(
+ menu.prepareWidgetAction(name, CompanionWidgets.RENAME, "").orElseThrow());
+ }
+ case 3 -> {
+ if (menu.widgetPending(name)) return false;
+ if (!nameIs(s, name, "draft ex"))
+ throw new AssertionError("Rejected edit changed server name");
+ Screenshot.grab(
+ mc.gameDirectory, "widget-rejection.png", mc.getMainRenderTarget(), message -> {});
+ click(s, n.x() + 8, n.y() + 8);
+ type(s, "discard");
+ s.keyPressed(256, 0, 0); // Cancel draft, retaining the editor and screen.
+ s.keyPressed(257, 0, 0);
+ }
+ case 4 -> {
+ if (menu.widgetPending(name)) return false;
+ if (!nameIs(s, name, "draft ex")) throw new AssertionError("Escape committed a draft");
+ click(s, n.x() + 8, n.y() + 8);
+ type(s, "hidden draft");
+ var context = s.bounds(menu.bound.screen().find("panel").orElseThrow());
+ click(s, context.x() + 5, context.y() + 5);
+ }
+ case 5 -> {
+ if (s.visible(name)) return false;
+ if (s.charTyped('z', 0)) throw new AssertionError("Hidden widget retained keyboard focus");
+ var open = s.bounds(menu.bound.screen().find("open_settings").orElseThrow());
+ click(s, open.x() + 8, open.y() + 8);
+ }
+ case 6 -> {
+ if (!s.visible(name)) return false;
+ click(s, n.x() + 20, n.y() + 28); // Hidden draft must have been discarded.
+ }
+ case 7 -> {
+ if (menu.widgetPending(name)) return false;
+ if (!nameIs(s, name, "draft ex"))
+ throw new AssertionError("Hidden draft survived cancellation");
+ s.mouseClicked(r.x() + 8, r.y() + 20, 0);
+ s.mouseDragged(r.right() + 200, r.bottom() + 100, 0, 200, 100);
+ if (menu.widgetPending(slider) || ((Number) menu.value(slider, false)).intValue() != 0)
+ throw new AssertionError("Drag preview was sent before release");
+ s.mouseReleased(r.right() + 200, r.bottom() + 100, 0);
+ }
+ case 8 -> {
+ if (menu.widgetPending(slider) || ((Number) menu.value(slider, false)).intValue() != 100)
+ return false;
+ s.mouseClicked(r.x() + 10, r.y() + 20, 0);
+ s.resize(mc, s.width, s.height);
+ s.mouseReleased(r.x() + 10, r.y() + 20, 0);
+ if (menu.widgetPending(slider))
+ throw new AssertionError("Resize committed a cancelled drag");
+ s.mouseClicked(r.x() + 20, r.y() + 20, 0);
+ menu.navigate("panel", "machine"); // Simulate a server-driven view change mid-drag.
+ s.mouseReleased(r.x() + 20, r.y() + 20, 0);
+ if (menu.widgetPending(slider)) throw new AssertionError("Hidden drag committed");
+ menu.navigate("panel", "settings");
+ }
+ case 9 -> s.mouseScrolled(r.x() + 20, r.y() + 20, 0, -1);
+ case 10 -> {
+ if (menu.widgetPending(slider) || ((Number) menu.value(slider, false)).intValue() != 99)
+ return false;
+ s.keyPressed(258, 0, 0);
+ s.keyPressed(258, 0, 0); // Text -> slider.
+ s.keyPressed(263, 0, 1); // Shift-left: ten percent.
+ }
+ case 11 -> {
+ if (menu.widgetPending(slider) || ((Number) menu.value(slider, false)).intValue() != 89)
+ return false;
+ click(s, l.right() - 20, l.y() + 22);
+ }
+ case 12 -> {
+ if (menu.widgetPending(selector) || !Boolean.TRUE.equals(menu.value(selector, false)))
+ return false;
+ Screenshot.grab(
+ mc.gameDirectory, "widget-settings.png", mc.getMainRenderTarget(), message -> {});
+ click(s, l.x() + 20, l.y() + 22);
+ }
+ case 13 -> {
+ if (menu.widgetPending(selector) || !Boolean.FALSE.equals(menu.value(selector, false)))
+ return false;
+ click(s, r.x() + 3, r.y() + 20); // Restore reserve for the remaining machine tests.
+ }
+ case 14 -> {
+ if (menu.widgetPending(slider) || ((Number) menu.value(slider, false)).intValue() != 0)
+ return false;
+ var context = s.bounds(menu.bound.screen().find("panel").orElseThrow());
+ click(s, context.x() + 5, context.y() + 5);
+ }
+ case 15 -> {
+ if (s.visible(name)) return false;
+ var open = s.bounds(menu.bound.screen().find("open_settings").orElseThrow());
+ click(s, open.x() + 8, open.y() + 8);
+ }
+ case 16 -> {
+ if (!s.visible(name)) return false;
+ var delayed =
+ menu.prepareWidgetAction(name, CompanionWidgets.RENAME, "Late edit").orElseThrow();
+ // A recipe browser can cover the machine while its menu remains open. Let real client
+ // ticks expire the request, then deliver it late to check ordinary value convergence.
+ mc.setScreen(
+ new net.minecraft.client.gui.screens.Screen(Component.literal("Cover")) {
+ private int ticks;
+
+ @Override
+ public boolean isPauseScreen() {
+ return false;
+ }
+
+ @Override
+ public void tick() {
+ if (++ticks < 105) return;
+ if (menu.widgetPending(name))
+ throw new AssertionError("Covered screen left the editor pending");
+ PacketDistributor.sendToServer(delayed);
+ mc.setScreen(s);
+ }
+ });
+ }
+ case 17 -> {
+ if (!nameIs(s, name, "Late edit")) return false;
+ PacketDistributor.sendToServer(
+ menu.prepareWidgetAction(name, CompanionWidgets.RENAME, "draft ex").orElseThrow());
+ }
+ case 18 -> {
+ if (menu.widgetPending(name) || !nameIs(s, name, "draft ex")) return false;
+ var context = s.bounds(menu.bound.screen().find("panel").orElseThrow());
+ click(s, context.x() + 5, context.y() + 5);
+ }
+ default -> {
+ if (s.visible(name)) return false;
+ return true;
+ }
+ }
+ step++;
+ return false;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java b/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java
new file mode 100644
index 00000000..6530a13f
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/WidgetAction.java
@@ -0,0 +1,75 @@
+package zone.moddev.mc.lib.gui;
+
+import java.util.Objects;
+import java.util.function.BiPredicate;
+
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerPlayer;
+
+import io.netty.buffer.Unpooled;
+
+import zone.moddev.mc.lib.gui.model.Element;
+import zone.moddev.mc.lib.gui.runtime.MachineMenu;
+
+/**
+ * A typed action allowed by a widget type. Codecs must bound strings/collections before allocating.
+ * The server handler must validate permissions and data before mutating, returning false to reject.
+ * Register the same ID and codec on both sides; the handler only runs on the server thread.
+ */
+public record WidgetAction(
+ ResourceLocation id,
+ StreamCodec codec,
+ int maxBytes,
+ BiPredicate handler) {
+ public static final int MAX_BYTES = 4096;
+
+ public WidgetAction {
+ Objects.requireNonNull(id);
+ Objects.requireNonNull(codec);
+ Objects.requireNonNull(handler);
+ if (maxBytes < 1 || maxBytes > MAX_BYTES)
+ throw new IllegalArgumentException("Widget action limit must be 1..4096 bytes");
+ }
+
+ public record Context(ServerPlayer player, MachineMenu menu, Element.Custom widget) {}
+
+ /** Encodes inside a bounded buffer, before any packet is sent. */
+ public byte[] encode(RegistryAccess registries, T value) {
+ var buffer =
+ new RegistryFriendlyByteBuf(Unpooled.buffer(Math.min(64, maxBytes), maxBytes), registries);
+ try {
+ codec.encode(buffer, value);
+ byte[] data = new byte[buffer.readableBytes()];
+ buffer.readBytes(data);
+ return data;
+ } finally {
+ buffer.release();
+ }
+ }
+
+ /** Decodes exactly one action, rejecting trailing data as well as oversized payloads. */
+ public T decode(RegistryAccess registries, byte[] data) {
+ if (data.length > maxBytes) throw new IllegalArgumentException("Widget action is too large");
+ var buffer = new RegistryFriendlyByteBuf(Unpooled.wrappedBuffer(data), registries);
+ try {
+ T value = Objects.requireNonNull(codec.decode(buffer));
+ if (buffer.isReadable()) throw new IllegalArgumentException("Trailing widget action data");
+ return value;
+ } finally {
+ buffer.release();
+ }
+ }
+
+ public boolean dispatch(Context context, byte[] data) {
+ T value;
+ try {
+ value = decode(context.player().registryAccess(), data);
+ } catch (RuntimeException malformed) {
+ return false;
+ }
+ return handler.test(context, value);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java b/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java
new file mode 100644
index 00000000..311e567e
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/WidgetType.java
@@ -0,0 +1,38 @@
+package zone.moddev.mc.lib.gui;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import zone.moddev.mc.lib.gui.load.BoundValue.Kind;
+import zone.moddev.mc.lib.gui.model.Element;
+
+/**
+ * A mod-defined leaf widget's common-side contract. Register it during mod construction, before
+ * screens load. The validator runs on both sides at startup and must not access client classes or a
+ * live world. Reject unsupported properties and missing bindings with IllegalArgumentException. An
+ * empty valueKinds set forbids a value binding; max bindings are always numeric. Only the typed
+ * actions listed here may be invoked by this widget. Their handlers run on the server.
+ */
+public record WidgetType(
+ Set valueKinds,
+ Consumer validator,
+ java.util.List> actions) {
+ public WidgetType(Set valueKinds, Consumer validator) {
+ this(valueKinds, validator, java.util.List.of());
+ }
+
+ public WidgetType {
+ valueKinds = Set.copyOf(valueKinds);
+ Objects.requireNonNull(validator, "validator");
+ actions = java.util.List.copyOf(actions);
+ var ids = new java.util.HashSet();
+ for (var action : actions)
+ if (!ids.add(action.id()))
+ throw new IllegalArgumentException("Duplicate widget action: " + action.id());
+ }
+
+ public java.util.Optional> action(net.minecraft.resources.ResourceLocation id) {
+ return actions.stream().filter(action -> action.id().equals(id)).findFirst();
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/WidgetTypes.java b/src/main/java/zone/moddev/mc/lib/gui/WidgetTypes.java
new file mode 100644
index 00000000..48b87fd9
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/WidgetTypes.java
@@ -0,0 +1,38 @@
+package zone.moddev.mc.lib.gui;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import net.minecraft.resources.ResourceLocation;
+
+/** Common-side widget contracts. No renderer or other client class belongs in this registry. */
+public final class WidgetTypes {
+ /** Register mod widgets here from the mod constructor, on both physical sides. */
+ public static final WidgetTypes GLOBAL = new WidgetTypes();
+
+ private final Map types = new HashMap<>();
+ private boolean frozen;
+
+ /** Registers one type. IDs cannot be replaced, even with the same definition. */
+ public synchronized void register(ResourceLocation id, WidgetType type) {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(type, "type");
+ if (frozen)
+ throw new IllegalStateException("Widget types are frozen; register during mod construction");
+ if (types.putIfAbsent(id, type) != null)
+ throw new IllegalArgumentException("Duplicate widget type: " + id);
+ }
+
+ /** Finds a type, failing explicitly for an unavailable mod or a misspelled ID. */
+ public synchronized WidgetType require(ResourceLocation id) {
+ var type = types.get(id);
+ if (type == null) throw new IllegalArgumentException("Unknown widget type: " + id);
+ return type;
+ }
+
+ /** Called before startup screen discovery so all screens use the same contracts. */
+ public synchronized void freeze() {
+ frozen = true;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java
index cba04c45..233c7c0b 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java
@@ -22,6 +22,8 @@
/** Client presentation of a data-defined machine; getters are never invoked here. */
public class MachineScreen extends AbstractContainerScreen {
public final ScreenLayout layout;
+ private WidgetInput widgetInput;
+ private boolean widgetsRemoved;
private final Set open = Collections.newSetFromMap(new IdentityHashMap<>());
private final Map scroll = new IdentityHashMap<>();
private final Map groups = new IdentityHashMap<>();
@@ -42,12 +44,56 @@ public class MachineScreen extends AbstractContainerScreen {
public MachineScreen(MachineMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
+ WidgetRenderers.GLOBAL.validate(menu.bound.screen());
layout =
ScreenLayout.of(
menu.bound.screen(),
text -> net.minecraft.client.Minecraft.getInstance().font.width(text(text)));
imageWidth = layout.panel().width();
imageHeight = layout.panel().height();
+ widgetInput = createWidgets();
+ }
+
+ private WidgetInput createWidgets() {
+ return new WidgetInput(
+ menu.elements.stream()
+ .filter(Element.Custom.class::isInstance)
+ .map(Element.Custom.class::cast)
+ .toList(),
+ widget ->
+ WidgetRenderers.GLOBAL
+ .require(widget.type())
+ .create(
+ new WidgetAccess() {
+ public Element.Custom element() {
+ return widget;
+ }
+
+ public Rect bounds() {
+ return MachineScreen.this.bounds(widget);
+ }
+
+ public Object value() {
+ return menu.value(widget, false);
+ }
+
+ public Object maximum() {
+ return menu.value(widget, true);
+ }
+
+ public boolean pending() {
+ return menu.widgetPending(widget);
+ }
+
+ public boolean submit(
+ zone.moddev.mc.lib.gui.WidgetAction action, T value) {
+ var packet = menu.prepareWidgetAction(widget, action, value);
+ packet.ifPresent(PacketDistributor::sendToServer);
+ return packet.isPresent();
+ }
+ }),
+ this::visible,
+ this::bounds);
}
@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
@@ -61,9 +107,25 @@ public static void register(RegisterMenuScreensEvent event) {
}
}
+ @EventBusSubscriber(modid = "mmdlib", value = Dist.CLIENT)
+ public static final class PendingActions {
+ @SubscribeEvent
+ public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post event) {
+ var player = net.minecraft.client.Minecraft.getInstance().player;
+ if (player != null && player.containerMenu instanceof MachineMenu menu)
+ menu.tickWidgetActions();
+ }
+ }
+
@Override
protected void init() {
super.init();
+ // Recipe browsers can return to the same screen object after removed() was called.
+ if (widgetsRemoved) {
+ widgetInput = createWidgets();
+ widgetsRemoved = false;
+ }
+ widgetInput.resized();
if (restored) return;
restored = true;
if (REMEMBERED.containsKey(menu.bound.screen().id())) {
@@ -163,7 +225,7 @@ protected void renderLabels(GuiGraphics g, int x, int y) {}
protected void renderBg(GuiGraphics g, float partial, int mx, int my) {
sprite(g, "panel", layout.panel().offset(leftPos, topPos));
caption(g, text(menu.bound.screen().title()), leftPos + 7, topPos + 6);
- for (var e : menu.elements) if (visible(e)) draw(g, e, bounds(e), mx, my);
+ for (var e : menu.elements) if (visible(e)) draw(g, e, bounds(e), mx, my, partial);
layout
.inventory()
.ifPresent(
@@ -186,8 +248,23 @@ protected void renderBg(GuiGraphics g, float partial, int mx, int my) {
});
}
- private void draw(GuiGraphics g, Element e, Rect r, int mx, int my) {
+ private void draw(GuiGraphics g, Element e, Rect r, int mx, int my, float partial) {
switch (e) {
+ case Element.Custom custom -> {
+ g.pose().pushPose();
+ g.enableScissor(r.x(), r.y(), r.right(), r.bottom());
+ try {
+ widgetInput
+ .instance(custom)
+ .render(
+ new WidgetRenderer.Context(
+ g, custom, r, menu.value(e, false), menu.value(e, true), mx, my, partial));
+ } finally {
+ g.setColor(1, 1, 1, 1);
+ g.disableScissor();
+ g.pose().popPose();
+ }
+ }
case Element.Group group -> {
tinted(g, "group", r, group.tint().orElse(0xffffff));
group
@@ -446,6 +523,7 @@ else if (value instanceof List> list)
@Override
public void render(GuiGraphics g, int mx, int my, float partial) {
+ updateWidgets();
super.render(g, mx, my, partial);
Element next = null;
for (var e : menu.elements) if (visible(e) && bounds(e).contains(mx, my)) next = e;
@@ -459,7 +537,7 @@ public void render(GuiGraphics g, int mx, int my, float partial) {
.common()
.hoverShows()
.flatMap(id -> menu.bound.screen().find(id))
- .ifPresent(e -> draw(g, e, bounds(e), mx, my));
+ .ifPresent(e -> draw(g, e, bounds(e), mx, my, partial));
for (var p : open) {
Rect r = popBounds(p);
Rect tab = bounds(p);
@@ -568,16 +646,27 @@ private void send(Element e, int kind, int button, int option) {
@Override
public boolean mouseClicked(double mx, double my, int button) {
- if (button > 1) return super.mouseClicked(mx, my, button);
- boolean onDrawerTab = menu.bound.screen().tabs().stream()
- .anyMatch(e -> e instanceof Element.Popout && bounds(e).contains(mx, my));
+ updateWidgets();
+ if (widgetInput.capturing()) return true;
+ widgetInput.beginClick();
+ boolean onDrawerTab =
+ menu.bound.screen().tabs().stream()
+ .anyMatch(e -> e instanceof Element.Popout && bounds(e).contains(mx, my));
for (var p : open)
- if (popBounds(p).contains(mx, my)
- || drawerRail(p).contains(mx, my) && !onDrawerTab) return true;
+ if (button >= 0
+ && button <= 1
+ && (popBounds(p).contains(mx, my) || drawerRail(p).contains(mx, my) && !onDrawerTab)) {
+ widgetInput.clearFocus();
+ return true;
+ }
for (int i = menu.elements.size() - 1; i >= 0; i--) {
var e = menu.elements.get(i);
Rect r = bounds(e);
if (!visible(e) || !r.contains(mx, my)) continue;
+ widgetInput.blurUnless(e);
+ if (e instanceof Element.Custom custom && widgetInput.click(custom, mx, my, button))
+ return true;
+ if (button > 1) continue;
if (e instanceof Element.Popout p) {
if (!open.remove(p)) {
open.removeIf(other -> other.edge() == p.edge());
@@ -640,6 +729,7 @@ public boolean mouseClicked(double mx, double my, int button) {
return true;
}
}
+ widgetInput.clearFocus();
return super.mouseClicked(mx, my, button);
}
@@ -655,6 +745,8 @@ private void back(Element.Context c) {
@Override
public boolean keyPressed(int key, int scan, int modifiers) {
+ updateWidgets();
+ if (widgetInput.keyPressed(key, scan, modifiers)) return true;
if (key == 256)
for (var e : menu.elements)
if (e instanceof Element.Context c
@@ -669,6 +761,8 @@ public boolean keyPressed(int key, int scan, int modifiers) {
@Override
public void removed() {
+ widgetInput.removed();
+ widgetsRemoved = true;
hover(hovered, false);
hovered = null;
super.removed();
@@ -676,11 +770,52 @@ public void removed() {
@Override
public boolean mouseScrolled(double mx, double my, double dx, double dy) {
+ updateWidgets();
for (var p : open)
if (popBounds(p).contains(mx, my)) {
scroll.merge(p, (int) (-dy * 10), Integer::sum);
return true;
}
- return super.mouseScrolled(mx, my, dx, dy);
+ return widgetInput.scroll(mx, my, dx, dy) || super.mouseScrolled(mx, my, dx, dy);
+ }
+
+ private void updateWidgets() {
+ widgetInput.update();
+ for (var e : menu.elements)
+ if (e instanceof Element.Custom custom) {
+ var result = menu.takeWidgetResult(custom);
+ if (result != null) widgetInput.instance(custom).actionResult(result.accepted());
+ }
+ }
+
+ @Override
+ protected void containerTick() {
+ super.containerTick();
+ updateWidgets();
+ widgetInput.tick();
+ }
+
+ @Override
+ public boolean mouseDragged(double x, double y, int button, double dx, double dy) {
+ updateWidgets();
+ return widgetInput.drag(x, y, button, dx, dy) || super.mouseDragged(x, y, button, dx, dy);
+ }
+
+ @Override
+ public boolean mouseReleased(double x, double y, int button) {
+ updateWidgets();
+ return widgetInput.release(x, y, button) || super.mouseReleased(x, y, button);
+ }
+
+ @Override
+ public boolean charTyped(char character, int modifiers) {
+ updateWidgets();
+ return widgetInput.charTyped(character, modifiers) || super.charTyped(character, modifiers);
+ }
+
+ @Override
+ public boolean keyReleased(int key, int scan, int modifiers) {
+ updateWidgets();
+ return widgetInput.keyReleased(key, scan, modifiers) || super.keyReleased(key, scan, modifiers);
}
}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java
new file mode 100644
index 00000000..0feca586
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetAccess.java
@@ -0,0 +1,21 @@
+package zone.moddev.mc.lib.gui.client;
+
+import zone.moddev.mc.lib.gui.WidgetAction;
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.model.Element;
+
+/** Live, client-only access for one widget instance. Values are server snapshots, never getters. */
+public interface WidgetAccess {
+ Element.Custom element();
+
+ Rect bounds();
+
+ Object value();
+
+ Object maximum();
+
+ boolean pending();
+
+ /** Sends one registered action; false means hidden, pending, or unavailable. */
+ boolean submit(WidgetAction action, T value);
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetFactory.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetFactory.java
new file mode 100644
index 00000000..b1258cf3
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetFactory.java
@@ -0,0 +1,7 @@
+package zone.moddev.mc.lib.gui.client;
+
+/** Creates a fresh interactive instance for each element in each open screen. */
+@FunctionalInterface
+public interface WidgetFactory {
+ WidgetInstance create(WidgetAccess access);
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java
new file mode 100644
index 00000000..1d0008db
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInput.java
@@ -0,0 +1,209 @@
+package zone.moddev.mc.lib.gui.client;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.model.Element.Custom;
+
+/**
+ * Focus, capture and lifecycle routing, independent of vanilla's inventory key handling. Call
+ * {@link #update()} once before routing an input event, tick or frame. All operations in that event
+ * share its visibility snapshot.
+ */
+public final class WidgetInput {
+ private final List order;
+ private final Map instances = new IdentityHashMap<>();
+ private final Map shown = new IdentityHashMap<>();
+ private final Predicate visible;
+ private final Function bounds;
+ private Custom focused, captured;
+ private int capturedButton = -1, cancelledButton = -1;
+ private boolean closed;
+
+ public WidgetInput(
+ List elements,
+ Function factory,
+ Predicate visible,
+ Function bounds) {
+ order = List.copyOf(elements);
+ this.visible = visible;
+ this.bounds = bounds;
+ for (var element : order)
+ instances.put(element, Objects.requireNonNull(factory.apply(element)));
+ var unique = Collections.newSetFromMap(new IdentityHashMap());
+ for (var instance : instances.values())
+ if (!unique.add(instance))
+ throw new IllegalArgumentException("Widget factories must return fresh instances");
+ }
+
+ public WidgetInstance instance(Custom element) {
+ return instances.get(element);
+ }
+
+ public Custom focused() {
+ return focused;
+ }
+
+ public boolean capturing() {
+ return captured != null;
+ }
+
+ /** A new physical press ends any suppression left over from a cancelled earlier gesture. */
+ public void beginClick() {
+ if (captured == null) cancelledButton = -1;
+ }
+
+ public void update() {
+ if (closed) return;
+ for (var element : order) {
+ boolean now = visible.test(element);
+ Boolean previous = shown.put(element, now);
+ if (!Objects.equals(previous, now)) {
+ if (!now) {
+ if (captured == element) cancelCapture();
+ else instances.get(element).cancelInteraction();
+ if (focused == element) focus(null);
+ }
+ instances.get(element).visibilityChanged(now);
+ }
+ }
+ }
+
+ private boolean isShown(Custom element) {
+ return Boolean.TRUE.equals(shown.get(element));
+ }
+
+ public void tick() {
+ if (!closed) for (var e : order) if (isShown(e)) instances.get(e).tick();
+ }
+
+ public void blurUnless(Object element) {
+ if (focused != element) focus(null);
+ }
+
+ public void clearFocus() {
+ focus(null);
+ }
+
+ private void focus(Custom element) {
+ if (focused == element) return;
+ if (focused != null) instances.get(focused).setFocused(false);
+ focused = element;
+ if (focused != null) instances.get(focused).setFocused(true);
+ }
+
+ /** Called in screen hit order, after checking drawers and other elements above this one. */
+ public boolean click(Custom element, double x, double y, int button) {
+ if (closed || !isShown(element)) return false;
+ if (captured != null) return true;
+ cancelledButton = -1;
+ var instance = instances.get(element);
+ var result = instance.mouseClicked(x, y, button);
+ if (result == WidgetInstance.Click.PASS) return false;
+ focus(instance.focusable() ? element : null);
+ if (result == WidgetInstance.Click.CAPTURE) {
+ captured = element;
+ capturedButton = button;
+ }
+ return true;
+ }
+
+ public boolean drag(double x, double y, int button, double dx, double dy) {
+ if (closed) return false;
+ if (captured == null) return button == cancelledButton;
+ if (button == capturedButton) instances.get(captured).mouseDragged(x, y, button, dx, dy);
+ return true;
+ }
+
+ public boolean release(double x, double y, int button) {
+ if (closed) return false;
+ if (button == cancelledButton) {
+ cancelledButton = -1;
+ return true;
+ }
+ // A release belongs to the control that captured the press, not the current hover target.
+ if (captured == null) return false;
+ if (button == capturedButton) {
+ var instance = instances.get(captured);
+ captured = null;
+ capturedButton = -1;
+ instance.mouseReleased(x, y, button);
+ }
+ return true;
+ }
+
+ public boolean scroll(double x, double y, double dx, double dy) {
+ if (closed) return false;
+ if (captured != null) return true;
+ for (var e : order.reversed())
+ if (isShown(e)
+ && bounds.apply(e).contains(x, y)
+ && instances.get(e).mouseScrolled(x, y, dx, dy)) return true;
+ return false;
+ }
+
+ public boolean keyPressed(int key, int scan, int modifiers) {
+ if (closed) return false;
+ if (key == 258) { // Tab / Shift+Tab.
+ cancelCapture();
+ var candidates =
+ order.stream().filter(this::isShown).filter(e -> instances.get(e).focusable()).toList();
+ if (candidates.isEmpty()) return false;
+ int index = -1;
+ for (int i = 0; i < candidates.size(); i++) if (candidates.get(i) == focused) index = i;
+ int direction = (modifiers & 1) != 0 ? -1 : 1;
+ focus(
+ candidates.get(
+ index < 0
+ ? (direction > 0 ? 0 : candidates.size() - 1)
+ : Math.floorMod(index + direction, candidates.size())));
+ return true;
+ }
+ if (key == 256 && captured != null) {
+ cancelCapture();
+ return true;
+ }
+ if (focused == null) return false;
+ boolean handled = instances.get(focused).keyPressed(key, scan, modifiers);
+ if (key == 256 && !handled) focus(null);
+ // Even unhandled inventory shortcuts must not reach slots while an editor has focus.
+ return true;
+ }
+
+ public boolean keyReleased(int key, int scan, int modifiers) {
+ if (closed) return false;
+ if (focused == null) return false;
+ instances.get(focused).keyReleased(key, scan, modifiers);
+ return true;
+ }
+
+ public boolean charTyped(char character, int modifiers) {
+ if (closed) return false;
+ if (focused == null) return false;
+ instances.get(focused).charTyped(character, modifiers);
+ return true;
+ }
+
+ private void cancelCapture() {
+ if (captured == null) return;
+ cancelledButton = capturedButton;
+ instances.get(captured).cancelInteraction();
+ captured = null;
+ capturedButton = -1;
+ }
+
+ public void resized() {
+ cancelCapture();
+ instances.values().forEach(WidgetInstance::resized);
+ }
+
+ public void removed() {
+ if (closed) return;
+ closed = true;
+ cancelCapture();
+ focus(null);
+ instances.values().forEach(WidgetInstance::removed);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java
new file mode 100644
index 00000000..5ac749ef
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetInstance.java
@@ -0,0 +1,62 @@
+package zone.moddev.mc.lib.gui.client;
+
+/**
+ * One widget's state for one open screen. Factories must return fresh instances. Coordinates are
+ * absolute GUI pixels. Only a captured press receives dragging and release, even outside its
+ * bounds. Hidden widgets lose focus and capture; resize preserves the instance and focus but
+ * cancels capture without committing.
+ */
+public interface WidgetInstance extends WidgetRenderer {
+ enum Click {
+ PASS,
+ CONSUME,
+ CAPTURE
+ }
+
+ default boolean focusable() {
+ return false;
+ }
+
+ default void setFocused(boolean focused) {}
+
+ default Click mouseClicked(double x, double y, int button) {
+ return Click.PASS;
+ }
+
+ default boolean mouseDragged(double x, double y, int button, double dx, double dy) {
+ return false;
+ }
+
+ default boolean mouseReleased(double x, double y, int button) {
+ return false;
+ }
+
+ default boolean mouseScrolled(double x, double y, double dx, double dy) {
+ return false;
+ }
+
+ default boolean keyPressed(int key, int scan, int modifiers) {
+ return false;
+ }
+
+ default boolean keyReleased(int key, int scan, int modifiers) {
+ return false;
+ }
+
+ default boolean charTyped(char character, int modifiers) {
+ return false;
+ }
+
+ default void tick() {}
+
+ default void resized() {}
+
+ default void visibilityChanged(boolean visible) {}
+
+ default void cancelInteraction() {}
+
+ /** Values have already been updated. False also covers an unconfirmed edit after timeout. */
+ default void actionResult(boolean accepted) {}
+
+ default void removed() {}
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java
new file mode 100644
index 00000000..7c22e51b
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderer.java
@@ -0,0 +1,41 @@
+package zone.moddev.mc.lib.gui.client;
+
+import net.minecraft.client.gui.GuiGraphics;
+
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.model.Element;
+
+/**
+ * Client-only drawing for a registered custom widget. Rendering is clipped to the widget bounds.
+ * Balance any pose/scissor pushes of your own and do not retain or mutate the synced values. Clicks
+ * and tooltips are handled by the screen using the widget's common XML attributes. For per-screen
+ * editing state and input, register a {@link WidgetFactory} returning a {@link WidgetInstance}.
+ */
+@FunctionalInterface
+public interface WidgetRenderer {
+ void render(Context context);
+
+ /**
+ * One frame's inputs. Bounds and mouse coordinates are absolute GUI pixels. Values are synced
+ * snapshots (whole numbers are BigInteger, decimals Double), or null before sync or on failure.
+ * No getter is called on the client. partialTick supports client-only animation.
+ */
+ record Context(
+ GuiGraphics graphics,
+ Element.Custom widget,
+ Rect bounds,
+ Object value,
+ Object maximum,
+ int mouseX,
+ int mouseY,
+ float partialTick) {
+ /** A clamped 0..1 numeric ratio, including large integers; unavailable values give zero. */
+ public double ratio() {
+ return ScreenText.ratio(value, maximum);
+ }
+
+ public boolean hovered() {
+ return bounds.contains(mouseX, mouseY);
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java
new file mode 100644
index 00000000..34b81ff4
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/WidgetRenderers.java
@@ -0,0 +1,86 @@
+package zone.moddev.mc.lib.gui.client;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import net.minecraft.resources.ResourceLocation;
+
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.fml.event.lifecycle.FMLLoadCompleteEvent;
+
+import zone.moddev.mc.lib.gui.Screens;
+import zone.moddev.mc.lib.gui.WidgetTypes;
+import zone.moddev.mc.lib.gui.load.ScreenException;
+import zone.moddev.mc.lib.gui.model.Element;
+import zone.moddev.mc.lib.gui.model.Screen;
+
+/** Client-only renderer registry. Register during client mod construction or client setup. */
+public final class WidgetRenderers {
+ public static final WidgetRenderers GLOBAL = new WidgetRenderers();
+
+ private final Map renderers = new HashMap<>();
+ private boolean frozen;
+
+ /** Registers a renderer; duplicate IDs and registration after load completion are errors. */
+ public synchronized void register(ResourceLocation type, WidgetRenderer renderer) {
+ Objects.requireNonNull(renderer, "renderer");
+ registerInteractive(
+ type,
+ access ->
+ new WidgetInstance() {
+ @Override
+ public void render(WidgetRenderer.Context context) {
+ renderer.render(context);
+ }
+ });
+ }
+
+ /** Registers a factory; it must create independent state for each widget in each screen. */
+ public synchronized void registerInteractive(ResourceLocation type, WidgetFactory renderer) {
+ Objects.requireNonNull(type, "type");
+ Objects.requireNonNull(renderer, "renderer");
+ if (frozen)
+ throw new IllegalStateException("Widget renderers are frozen; register during client setup");
+ if (renderers.putIfAbsent(type, renderer) != null)
+ throw new IllegalArgumentException("Duplicate widget renderer: " + type);
+ }
+
+ public synchronized WidgetFactory require(ResourceLocation type) {
+ var renderer = renderers.get(type);
+ if (renderer == null) throw new IllegalArgumentException("Missing widget renderer: " + type);
+ return renderer;
+ }
+
+ /** Checks a screen before it opens, naming the file and element line for missing renderers. */
+ public void validate(Screen screen) {
+ for (var element : screen.elements())
+ if (element instanceof Element.Custom custom) {
+ try {
+ require(custom.type());
+ } catch (IllegalArgumentException ex) {
+ throw new ScreenException(screen.source(), custom.common().line(), ex.getMessage(), ex);
+ }
+ }
+ }
+
+ /** Freezes registration and catches renderer IDs with no common-side definition. */
+ public synchronized void freeze(WidgetTypes types) {
+ renderers.keySet().forEach(types::require);
+ frozen = true;
+ }
+
+ @EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
+ public static final class Registration {
+ @SubscribeEvent
+ public static void complete(FMLLoadCompleteEvent event) {
+ event.enqueueWork(
+ () -> {
+ GLOBAL.freeze(WidgetTypes.GLOBAL);
+ Screens.all().values().forEach(bound -> GLOBAL.validate(bound.screen()));
+ });
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java
index 922aecab..6dc5314b 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java
@@ -106,6 +106,7 @@ private int[] size(Element element) {
return switch (element) {
case Box box -> boxSize(box);
case Spacer spacer -> new int[] {0, 0};
+ case Custom custom -> new int[] {custom.width(), custom.height()};
case Group group -> {
int[] inner = stack(group.children(), 2);
int label = group.label().isPresent() ? TextMeasure.LINE_HEIGHT + 2 : 0;
diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java
index 1ad937c8..35af7187 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenBinder.java
@@ -10,6 +10,7 @@
import java.util.Map;
import java.util.Set;
+import zone.moddev.mc.lib.gui.WidgetTypes;
import zone.moddev.mc.lib.gui.load.BoundValue.Kind;
import zone.moddev.mc.lib.gui.model.Element;
import zone.moddev.mc.lib.gui.model.Element.*;
@@ -55,6 +56,11 @@ public record Bound(
* public, has the wrong shape, or returns a kind the element cannot show
*/
public static Bound bind(Screen screen, ClassLoader loader) {
+ return bind(screen, loader, WidgetTypes.GLOBAL);
+ }
+
+ /** Binds a screen read with an explicit widget registry. */
+ public static Bound bind(Screen screen, ClassLoader loader, WidgetTypes widgets) {
var source = screen.source();
Class> owner = load(screen.blockEntity(), loader, source, 0, "block-entity class");
Class> base =
@@ -68,7 +74,8 @@ public static Bound bind(Screen screen, ClassLoader loader) {
if (c.value().isPresent())
values.put(
element,
- resolve(c.value().get(), owner, loader, source, c.line(), valueKinds(element)));
+ resolve(
+ c.value().get(), owner, loader, source, c.line(), valueKinds(element, widgets)));
if (c.max().isPresent())
maxima.put(element, resolve(c.max().get(), owner, loader, source, c.line(), NUMBERS));
}
@@ -78,8 +85,9 @@ public static Bound bind(Screen screen, ClassLoader loader) {
private static final Set NUMBERS = Set.of(Kind.WHOLE, Kind.DECIMAL, Kind.NUMBER);
/** What each element's {@code value} may return. */
- private static Set valueKinds(Element element) {
+ private static Set valueKinds(Element element, WidgetTypes widgets) {
return switch (element) {
+ case Custom custom -> widgets.require(custom.type()).valueKinds();
case Gauge gauge ->
gauge.color().isPresent()
? NUMBERS
@@ -180,12 +188,21 @@ private static Class> load(
}
private static String describe(Set kinds) {
- if (kinds.size() == Kind.values().length) return "a number, boolean, FluidStack or Component";
if (kinds.equals(NUMBERS)) return "a number";
- if (kinds.equals(Set.of(Kind.BOOLEAN))) return "a boolean";
- if (kinds.equals(Set.of(Kind.WHOLE))) return "a whole number";
- if (kinds.contains(Kind.FLUID)) return "a number or a FluidStack";
- if (kinds.contains(Kind.TEXT)) return "a Component or a list of Components";
- return "a number, boolean, FluidStack or Component";
+ if (kinds.isEmpty()) return "no value binding";
+ return kinds.stream()
+ .sorted()
+ .map(
+ kind ->
+ switch (kind) {
+ case WHOLE -> "a whole number";
+ case DECIMAL -> "a decimal number";
+ case NUMBER -> "a declared Number";
+ case BOOLEAN -> "a boolean";
+ case FLUID -> "a FluidStack";
+ case TEXT -> "a Component";
+ case TEXT_LIST -> "a collection of Components";
+ })
+ .collect(java.util.stream.Collectors.joining(" or "));
}
}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java
index 0e1293c1..f4a23e98 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenLoader.java
@@ -33,6 +33,7 @@ private ScreenLoader() {}
* @throws IllegalArgumentException if two files have the same ID
*/
public static Map load(List sources) {
+ zone.moddev.mc.lib.gui.WidgetTypes.GLOBAL.freeze();
var screens = new TreeMap();
for (var source : sources) {
var folder = source.root().resolve(SECTION);
diff --git a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java
index 0b88aee5..8727f592 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/load/ScreenReader.java
@@ -27,6 +27,7 @@
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;
+import zone.moddev.mc.lib.gui.WidgetTypes;
import zone.moddev.mc.lib.gui.model.Common;
import zone.moddev.mc.lib.gui.model.Element;
import zone.moddev.mc.lib.gui.model.Element.*;
@@ -61,8 +62,15 @@ private ScreenReader() {}
*/
public static Screen read(ResourceLocation id, String source, InputStream input)
throws IOException {
+ return read(id, source, input, WidgetTypes.GLOBAL);
+ }
+
+ /** Reads using an explicit widget registry, for tools that own their loading stage. */
+ public static Screen read(
+ ResourceLocation id, String source, InputStream input, WidgetTypes widgets)
+ throws IOException {
Node root = parse(source, input);
- var screen = new Builder(source).screen(id, root);
+ var screen = new Builder(source, widgets).screen(id, root);
check(screen);
return screen;
}
@@ -164,9 +172,11 @@ public void fatalError(SAXParseException ex) throws SAXException {
private static final class Builder {
private final String source;
+ private final WidgetTypes widgets;
- Builder(String source) {
+ Builder(String source, WidgetTypes widgets) {
this.source = source;
+ this.widgets = widgets;
}
Screen screen(ResourceLocation id, Node root) {
@@ -204,6 +214,7 @@ private Element element(Node node) {
enumValue(Align.class, node, "align"),
children(node));
case "spacer" -> new Spacer(c);
+ case "custom" -> custom(node, c);
case "group" ->
new Group(
c,
@@ -278,6 +289,31 @@ private Element element(Node node) {
};
}
+ private Custom custom(Node node, Common common) {
+ var properties = new LinkedHashMap();
+ for (var property : node.children()) {
+ var name = property.attributes().get("name");
+ if (properties.putIfAbsent(name, property.attributes().get("value")) != null)
+ throw error(property, "Duplicate widget property: " + name);
+ }
+ var element =
+ new Custom(
+ common,
+ id(node, "type").orElseThrow(),
+ size(node, "width", 1),
+ size(node, "height", 1),
+ properties);
+ try {
+ var type = widgets.require(element.type());
+ if (common.value().isPresent() && type.valueKinds().isEmpty())
+ throw new IllegalArgumentException("This widget does not accept a value binding");
+ type.validator().accept(element);
+ } catch (RuntimeException ex) {
+ throw new ScreenException(source, node.line(), element.type() + ": " + ex.getMessage(), ex);
+ }
+ return element;
+ }
+
/** Standard energy bindings can be overridden for machines with several buffers. */
private Node energyDefaults(Node node) {
var attributes = new LinkedHashMap<>(node.attributes());
diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/Element.java b/src/main/java/zone/moddev/mc/lib/gui/model/Element.java
index 605eb84b..7f17e42e 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/model/Element.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/model/Element.java
@@ -91,6 +91,46 @@ record Box(
/** {@code spacer}: takes the leftover room in its box. */
record Spacer(Common common) implements Element {}
+ /**
+ * A mod-defined leaf. Properties are immutable and sorted so screen fingerprints are stable
+ * across JVMs and XML property order. Property nodes are configuration, not child elements.
+ */
+ record Custom(
+ Common common,
+ ResourceLocation type,
+ int width,
+ int height,
+ java.util.Map properties)
+ implements Element {
+ public Custom {
+ java.util.Objects.requireNonNull(common, "common");
+ java.util.Objects.requireNonNull(type, "type");
+ if (width < 1 || width > 1024 || height < 1 || height > 1024)
+ throw new IllegalArgumentException("Custom widget dimensions must be from 1 to 1024 px");
+ properties = java.util.Collections.unmodifiableSortedMap(new java.util.TreeMap<>(properties));
+ }
+
+ /**
+ * Escape property values so punctuation cannot make different configurations fingerprint alike.
+ */
+ @Override
+ public String toString() {
+ var json = new com.google.gson.JsonObject();
+ properties.forEach(json::addProperty);
+ return "Custom[common="
+ + common
+ + ", type="
+ + type
+ + ", width="
+ + width
+ + ", height="
+ + height
+ + ", properties="
+ + json
+ + "]";
+ }
+ }
+
/**
* {@code group}: a recessed panel around its children.
*
diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java b/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java
index f989aaf1..b822e026 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java
@@ -54,6 +54,20 @@ public Optional find(String id) {
.findFirst();
}
+ /** Stable model fingerprint used to reject differing client/server screen definitions. */
+ public String fingerprint() {
+ String model =
+ id + "|" + title + "|" + blockEntity + "|" + playerInventory + "|" + body + "|" + tabs;
+ try {
+ return java.util.HexFormat.of()
+ .formatHex(
+ java.security.MessageDigest.getInstance("SHA-256")
+ .digest(model.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ } catch (java.security.NoSuchAlgorithmException impossible) {
+ throw new AssertionError(impossible);
+ }
+ }
+
private static void visit(Element element, Consumer visitor) {
visitor.accept(element);
element.children().forEach(child -> visit(child, visitor));
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java
index 39dc3835..e778dbbb 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java
@@ -31,6 +31,7 @@ public class MachineMenu extends AbstractContainerMenu {
public final ScreenBinder.Bound bound;
public final List elements;
private final Map views = new HashMap<>();
+ private final Map visibility = new IdentityHashMap<>();
private final Map elementIndexes = new IdentityHashMap<>();
private final Map values = new HashMap<>();
private final ScreenHost host;
@@ -40,9 +41,150 @@ public class MachineMenu extends AbstractContainerMenu {
private final Player owner;
private boolean initial = true;
private boolean interacting;
+ private long widgetSession = java.util.concurrent.ThreadLocalRandom.current().nextLong();
+ private int nextWidgetRequest;
+ private int lastWidgetRequest;
+ private long widgetActionTick = Long.MIN_VALUE;
+ private int widgetActionsThisTick;
+ private final Map pendingWidgets = new HashMap<>();
+ private final Map widgetResults = new HashMap<>();
+ private final Set widgetRefreshKeys = new HashSet<>();
+ private final Map widgetSentAt = new HashMap<>();
+ private int widgetClientTicks;
public MachineMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, readEntity(inventory, data));
+ widgetSession = data.readLong();
+ }
+
+ public MachineMenu(int id, Inventory inventory, BlockEntity entity, long session) {
+ this(id, inventory, entity);
+ widgetSession = session;
+ }
+
+ public long widgetSession() {
+ return widgetSession;
+ }
+
+ /** Client-side: at most one in-flight edit per widget. */
+ public Optional prepareWidgetAction(
+ Element.Custom widget, WidgetAction action, T value) {
+ int index = index(widget);
+ if (!visible(widget)
+ || pendingWidgets.containsKey(index)
+ || widgetResults.containsKey(index)
+ || nextWidgetRequest == Integer.MAX_VALUE) return Optional.empty();
+ if (WidgetTypes.GLOBAL.require(widget.type()).action(action.id()).orElse(null) != action)
+ throw new IllegalArgumentException("Action is not registered for " + widget.type());
+ byte[] data;
+ try {
+ data = action.encode(owner.registryAccess(), value);
+ } catch (RuntimeException failure) {
+ zone.moddev.mc.lib.MMDLib.LOGGER.error(
+ "Could not encode widget action: {}", action.id(), failure);
+ return Optional.empty();
+ }
+ int request = ++nextWidgetRequest;
+ pendingWidgets.put(index, request);
+ widgetSentAt.put(index, widgetClientTicks);
+ return Optional.of(
+ new ScreenNetwork.Widget(containerId, widgetSession, index, request, action.id(), data));
+ }
+
+ public boolean widgetPending(Element.Custom widget) {
+ return pendingWidgets.containsKey(index(widget));
+ }
+
+ public void receiveWidgetResult(ScreenNetwork.WidgetResult result) {
+ if (result.menu() != containerId
+ || result.session() != widgetSession
+ || !Objects.equals(pendingWidgets.get(result.element()), result.request())) return;
+ pendingWidgets.remove(result.element());
+ widgetSentAt.remove(result.element());
+ values.put(result.element() * 2, result.value());
+ values.put(result.element() * 2 + 1, result.maximum());
+ widgetResults.put(result.element(), result);
+ }
+
+ public ScreenNetwork.WidgetResult takeWidgetResult(Element.Custom widget) {
+ return widgetResults.remove(index(widget));
+ }
+
+ /** Client-side timeout prevents an unconfirmed edit from permanently disabling a control. */
+ public void tickWidgetActions() {
+ widgetClientTicks++;
+ for (var entry : new HashMap<>(widgetSentAt).entrySet())
+ if (widgetClientTicks - entry.getValue() >= 100) {
+ int index = entry.getKey();
+ receiveWidgetResult(
+ new ScreenNetwork.WidgetResult(
+ containerId,
+ widgetSession,
+ index,
+ pendingWidgets.get(index),
+ false,
+ values.get(index * 2),
+ values.get(index * 2 + 1)));
+ }
+ }
+
+ /** Server-side authorization precedes decoding. Excess edits are rejected without dispatch. */
+ public ScreenNetwork.WidgetResult widgetAction(
+ net.minecraft.server.level.ServerPlayer player, ScreenNetwork.Widget packet) {
+ if (player != owner
+ || player.containerMenu != this
+ || !stillValid(player)
+ || packet.menu() != containerId
+ || packet.session() != widgetSession
+ || packet.request() <= lastWidgetRequest
+ || packet.element() >= elements.size()) return null;
+ long tick = player.level().getGameTime();
+ if (widgetActionTick != tick) {
+ widgetActionTick = tick;
+ widgetActionsThisTick = 0;
+ }
+ lastWidgetRequest = packet.request();
+ if (!(elements.get(packet.element()) instanceof Element.Custom widget)) return null;
+ if (++widgetActionsThisTick > 8)
+ // Use the last sent snapshot: rate-limited requests must not invoke codecs or getters.
+ return widgetResult(packet, false, values);
+ boolean accepted = false;
+ var action = WidgetTypes.GLOBAL.require(widget.type()).action(packet.action());
+ if (visible(widget) && action.isPresent()) {
+ try {
+ accepted =
+ action.get().dispatch(new WidgetAction.Context(player, this, widget), packet.data());
+ } catch (RuntimeException failure) {
+ zone.moddev.mc.lib.MMDLib.LOGGER.error(
+ "Widget action failed: {}", packet.action(), failure);
+ }
+ }
+ if (accepted) blockEntity.setChanged();
+ ScreenSnapshots.invalidate(blockEntity);
+ var snapshot = ScreenSnapshots.read(blockEntity, bound);
+ return widgetResult(packet, accepted, snapshot);
+ }
+
+ private ScreenNetwork.WidgetResult widgetResult(
+ ScreenNetwork.Widget packet, boolean accepted, Map snapshot) {
+ var result =
+ new ScreenNetwork.WidgetResult(
+ containerId,
+ widgetSession,
+ packet.element(),
+ packet.request(),
+ accepted,
+ snapshot.get(packet.element() * 2),
+ snapshot.get(packet.element() * 2 + 1));
+ // The acknowledgment changes the client's values too. Include it in the delta baseline so
+ // a later return to the pre-action value is sent even if no regular broadcast saw the edit.
+ values.put(packet.element() * 2, result.value());
+ values.put(packet.element() * 2 + 1, result.maximum());
+ // A client may already have timed out and ignored this response. Also send the next normal
+ // snapshot for these keys so its confirmed values converge independently of acknowledgments.
+ widgetRefreshKeys.add(packet.element() * 2);
+ widgetRefreshKeys.add(packet.element() * 2 + 1);
+ return result;
}
private static BlockEntity readEntity(Inventory inventory, RegistryFriendlyByteBuf data) {
@@ -141,7 +283,8 @@ public boolean isActive() {
@Override
public void setChanged() {
super.setChanged();
- // Vanilla shift-click merges mutate the stack directly, bypassing handler callbacks.
+ // Vanilla shift-click merges mutate the stack directly, bypassing handler
+ // callbacks.
if (!client) {
if (items instanceof ItemStorage storage) storage.markChanged();
blockEntity.setChanged();
@@ -225,32 +368,31 @@ public void apply(Map delta) {
}
public boolean visible(Element element) {
- for (var root : bound.screen().body())
- if (visibleIn(root, element, true) != null)
- return Boolean.TRUE.equals(visibleIn(root, element, true));
- for (var root : bound.screen().tabs())
- if (visibleIn(root, element, true) != null)
- return Boolean.TRUE.equals(visibleIn(root, element, true));
- return false;
+ if (visibility.isEmpty()) {
+ for (var root : bound.screen().body()) cacheVisibility(root, true);
+ for (var root : bound.screen().tabs()) cacheVisibility(root, true);
+ }
+ return Boolean.TRUE.equals(visibility.get(element));
}
- private Boolean visibleIn(Element root, Element target, boolean shown) {
- if (root == target) return shown;
+ private void cacheVisibility(Element root, boolean shown) {
+ visibility.put(root, shown);
for (var child : root.children()) {
boolean next = shown;
if (root instanceof Element.Context c)
next &= child.common().id().orElse("").equals(views.get(c.common().id().orElse("")));
- Boolean found = visibleIn(child, target, next);
- if (found != null) return found;
+ cacheVisibility(child, next);
}
- return null;
}
public void navigate(String context, String view) {
var e = bound.screen().find(context).orElse(null);
if (e instanceof Element.Context c
- && c.views().stream().anyMatch(v -> v.common().id().orElse("").equals(view)))
+ && c.views().stream().anyMatch(v -> v.common().id().orElse("").equals(view))
+ && !Objects.equals(views.get(context), view)) {
views.put(context, view);
+ visibility.clear();
+ }
}
@Override
@@ -378,6 +520,8 @@ public void broadcastChanges() {
}
snapshot.putAll(reservationSnapshot());
var delta = ScreenValues.changed(values, snapshot, initial);
+ for (int key : widgetRefreshKeys) delta.put(key, snapshot.get(key));
+ widgetRefreshKeys.clear();
if (!delta.isEmpty()) {
values.putAll(delta);
PacketDistributor.sendToPlayer(player, new ScreenNetwork.Values(containerId, delta));
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java
index e89f76f7..6e80c43d 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java
@@ -91,8 +91,106 @@ public Type type() {
}
}
+ /** A menu-session-bound, typed widget action. Its body is bounded before a mod codec runs. */
+ public record Widget(
+ int menu, long session, int element, int request, ResourceLocation action, byte[] data)
+ implements CustomPacketPayload {
+ public Widget {
+ if (element < 0
+ || element > 4095
+ || request < 1
+ || data.length > zone.moddev.mc.lib.gui.WidgetAction.MAX_BYTES)
+ throw new IllegalArgumentException("Widget action outside protocol limits");
+ Objects.requireNonNull(action);
+ data = data.clone();
+ }
+
+ @Override
+ public byte[] data() {
+ return data.clone();
+ }
+
+ public static final Type TYPE = ScreenNetwork.type("widget");
+ public static final StreamCodec CODEC =
+ StreamCodec.of(
+ (b, p) -> {
+ b.writeVarInt(p.menu);
+ b.writeLong(p.session);
+ b.writeVarInt(p.element);
+ b.writeVarInt(p.request);
+ b.writeResourceLocation(p.action);
+ b.writeByteArray(p.data);
+ },
+ b ->
+ new Widget(
+ b.readVarInt(),
+ b.readLong(),
+ b.readVarInt(),
+ b.readVarInt(),
+ b.readResourceLocation(),
+ b.readByteArray(zone.moddev.mc.lib.gui.WidgetAction.MAX_BYTES)));
+
+ public Type type() {
+ return TYPE;
+ }
+ }
+
+ /** Acknowledges the edit with fresh authoritative values, including on rejection. */
+ public record WidgetResult(
+ int menu,
+ long session,
+ int element,
+ int request,
+ boolean accepted,
+ Object value,
+ Object maximum)
+ implements CustomPacketPayload {
+ public static final Type TYPE = ScreenNetwork.type("widget_result");
+ public static final StreamCodec CODEC =
+ StreamCodec.of(
+ (b, p) -> {
+ b.writeVarInt(p.menu);
+ b.writeLong(p.session);
+ b.writeVarInt(p.element);
+ b.writeVarInt(p.request);
+ b.writeBoolean(p.accepted);
+ ScreenValues.write(b, p.value);
+ ScreenValues.write(b, p.maximum);
+ },
+ b ->
+ new WidgetResult(
+ b.readVarInt(),
+ b.readLong(),
+ b.readVarInt(),
+ b.readVarInt(),
+ b.readBoolean(),
+ ScreenValues.read(b),
+ ScreenValues.read(b)));
+
+ public Type type() {
+ return TYPE;
+ }
+ }
+
public static void register(RegisterPayloadHandlersEvent event) {
- var r = event.registrar("1");
+ var r = event.registrar("2");
+ r.playToServer(
+ Widget.TYPE,
+ Widget.CODEC,
+ (p, c) -> {
+ if (c.player() instanceof net.minecraft.server.level.ServerPlayer player
+ && player.containerMenu instanceof MachineMenu menu) {
+ var result = menu.widgetAction(player, p);
+ if (result != null)
+ net.neoforged.neoforge.network.PacketDistributor.sendToPlayer(player, result);
+ }
+ });
+ r.playToClient(
+ WidgetResult.TYPE,
+ WidgetResult.CODEC,
+ (p, c) -> {
+ if (c.player().containerMenu instanceof MachineMenu menu) menu.receiveWidgetResult(p);
+ });
r.playToClient(
Values.TYPE,
Values.CODEC,
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java
index c4370a66..20520903 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenRuntime.java
@@ -87,26 +87,7 @@ private static void requireCallback(
}
public static String fingerprint(zone.moddev.mc.lib.gui.model.Screen screen) {
- String model =
- screen.id()
- + "|"
- + screen.title()
- + "|"
- + screen.blockEntity()
- + "|"
- + screen.playerInventory()
- + "|"
- + screen.body()
- + "|"
- + screen.tabs();
- try {
- return java.util.HexFormat.of()
- .formatHex(
- java.security.MessageDigest.getInstance("SHA-256")
- .digest(model.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
- } catch (java.security.NoSuchAlgorithmException impossible) {
- throw new AssertionError(impossible);
- }
+ return screen.fingerprint();
}
public static boolean open(Player player, BlockEntity entity) {
@@ -114,6 +95,7 @@ public static boolean open(Player player, BlockEntity entity) {
var bound = Screens.get(BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(entity.getType()));
if (bound.isEmpty() || !bound.get().blockEntityClass().isInstance(entity)) return false;
if (player instanceof ServerPlayer server) {
+ long session = java.util.concurrent.ThreadLocalRandom.current().nextLong();
var text = bound.get().screen().title();
Component title =
text.fallback()
@@ -121,10 +103,11 @@ public static boolean open(Player player, BlockEntity entity) {
.orElseGet(() -> Component.translatable(text.key()));
server.openMenu(
new SimpleMenuProvider(
- (id, inventory, p) -> new MachineMenu(id, inventory, entity), title),
+ (id, inventory, p) -> new MachineMenu(id, inventory, entity, session), title),
buf -> {
buf.writeBlockPos(entity.getBlockPos());
buf.writeUtf(fingerprint(bound.get().screen()));
+ buf.writeLong(session);
});
}
return true;
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java
index 57a932b3..fff32816 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenSnapshots.java
@@ -18,6 +18,10 @@ private record Entry(long tick, ScreenBinder.Bound screen, Map
private static final Map CACHE = new WeakHashMap<>();
private static final Set FAILURES = new HashSet<>();
+ static void invalidate(BlockEntity entity) {
+ CACHE.remove(entity);
+ }
+
static Map read(BlockEntity entity, ScreenBinder.Bound bound) {
long tick = entity.getLevel().getGameTime();
var old = CACHE.get(entity);
diff --git a/src/main/resources/mmdlib/schema/screen.xsd b/src/main/resources/mmdlib/schema/screen.xsd
index c5c24acd..e1fb4fcf 100644
--- a/src/main/resources/mmdlib/schema/screen.xsd
+++ b/src/main/resources/mmdlib/schema/screen.xsd
@@ -136,6 +136,7 @@
+
@@ -161,6 +162,24 @@
+
+ A registered mod widget. Its type validates the named properties at startup.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The root. title is a translation key; block-entity is the class whose getters #method values call.
diff --git a/src/test/java/zone/moddev/mc/lib/gui/CustomWidgetsTest.java b/src/test/java/zone/moddev/mc/lib/gui/CustomWidgetsTest.java
new file mode 100644
index 00000000..89388b44
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/lib/gui/CustomWidgetsTest.java
@@ -0,0 +1,245 @@
+package zone.moddev.mc.lib.gui;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Set;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+import zone.moddev.mc.lib.gui.client.WidgetRenderers;
+import zone.moddev.mc.lib.gui.layout.ScreenLayout;
+import zone.moddev.mc.lib.gui.layout.TextMeasure;
+import zone.moddev.mc.lib.gui.load.BoundValue.Kind;
+import zone.moddev.mc.lib.gui.load.ScreenBinder;
+import zone.moddev.mc.lib.gui.load.ScreenException;
+import zone.moddev.mc.lib.gui.load.ScreenReader;
+import zone.moddev.mc.lib.gui.model.Element;
+import zone.moddev.mc.lib.gui.model.Screen;
+
+class CustomWidgetsTest {
+ private static final ResourceLocation ID = ResourceLocation.parse("example:dial");
+ private static final String HEAD =
+ """
+
+ """;
+
+ private WidgetTypes registry() {
+ var types = new WidgetTypes();
+ types.register(
+ ID,
+ new WidgetType(
+ Set.of(Kind.WHOLE),
+ widget -> {
+ if (!Set.of("color", "style").containsAll(widget.properties().keySet()))
+ throw new IllegalArgumentException("Unsupported dial property");
+ if (widget.common().value().isEmpty() || widget.common().max().isEmpty())
+ throw new IllegalArgumentException("Dial needs value and max");
+ }));
+ return types;
+ }
+
+ private Screen read(WidgetTypes types, String body) throws Exception {
+ return ScreenReader.read(
+ ID,
+ "custom.xml",
+ new ByteArrayInputStream((HEAD + body + "").getBytes(StandardCharsets.UTF_8)),
+ types);
+ }
+
+ private String widget(String attributes, String properties) {
+ return ""
+ + properties
+ + "";
+ }
+
+ private static final String VALUES = "value=\"#getProgress\" max=\"#getProgressMax\"";
+
+ @Test
+ void composesBindsAndPreservesCommonAttributes() throws Exception {
+ var types = registry();
+ var screen =
+ read(
+ types,
+ ""
+ + widget(
+ VALUES
+ + " on-click=\"example:toggle\" on-hover=\"example:inspect\""
+ + " tooltip=\"screen.example.tip\"",
+ "")
+ + "");
+ var custom = (Element.Custom) screen.find("dial").orElseThrow();
+ var bound = ScreenBinder.bind(screen, getClass().getClassLoader(), types);
+ assertEquals(Kind.WHOLE, bound.values().get(custom).kind());
+ assertEquals(Kind.WHOLE, bound.maxima().get(custom).kind());
+ assertEquals(Map.of("color", "#ABCDEF"), custom.properties());
+ assertThrows(
+ UnsupportedOperationException.class, () -> custom.properties().put("color", "red"));
+ assertTrue(custom.children().isEmpty());
+ assertEquals(3, screen.elements().size(), "Properties must not consume menu element indexes");
+ assertEquals("example:toggle", custom.common().clicks().click().orElseThrow().toString());
+ assertEquals("example:inspect", custom.common().onHover().orElseThrow().toString());
+ var layout = ScreenLayout.of(screen, TextMeasure.NONE);
+ var rect = layout.bounds(custom);
+ assertEquals(27, rect.width());
+ assertEquals(18, rect.height());
+ assertEquals(rect.right() + 4, layout.bounds(screen.elements().getLast()).x());
+ }
+
+ @Test
+ void fingerprintsIncludeCustomConfigurationInCanonicalOrder() throws Exception {
+ var types = registry();
+ String color = "";
+ String style = "";
+ var a = read(types, widget(VALUES, color + style));
+ var b = read(types, widget(VALUES, style + color));
+ assertEquals(a.fingerprint(), b.fingerprint());
+ assertNotEquals(
+ a.fingerprint(),
+ read(types, widget(VALUES, color.replace("ABCDEF", "000000") + style)).fingerprint());
+ assertNotEquals(
+ a.fingerprint(),
+ read(types, widget(VALUES, color + style).replace("1.5s", "2s")).fingerprint());
+ // Map.toString would turn both configurations into {color=a, style=b}.
+ assertNotEquals(
+ read(types, widget(VALUES, ""))
+ .fingerprint(),
+ read(
+ types,
+ widget(
+ VALUES,
+ ""))
+ .fingerprint());
+ }
+
+ @Test
+ void rejectsBadDefinitionsWithSourceLocations() {
+ var types = registry();
+ for (var body :
+ new String[] {
+ widget(VALUES, "").replace("example:dial", "example:missing"),
+ widget(VALUES, ""),
+ widget(
+ VALUES,
+ ""),
+ widget("", ""),
+ widget(VALUES, "").replace("1.5s", "0"),
+ widget(VALUES, "").replace("1.5s", "1025"),
+ widget(VALUES + " typo=\"x\"", ""),
+ widget(VALUES, ""),
+ widget(VALUES, "")
+ }) {
+ var error = assertThrows(ScreenException.class, () -> read(types, body), body);
+ assertTrue(error.getMessage().contains("custom.xml:3:"), error.getMessage());
+ }
+ }
+
+ @Test
+ void validatorRuntimeFailuresKeepLocationTypeAndCause() {
+ for (var failure :
+ new RuntimeException[] {
+ new IllegalStateException("Bad configuration"),
+ new NullPointerException("Missing property")
+ }) {
+ var types = new WidgetTypes();
+ types.register(
+ ID,
+ new WidgetType(
+ Set.of(Kind.WHOLE),
+ widget -> {
+ throw failure;
+ }));
+ var error = assertThrows(ScreenException.class, () -> read(types, widget(VALUES, "")));
+ assertTrue(
+ error.getMessage().contains("custom.xml:3: example:dial: " + failure.getMessage()));
+ assertSame(failure, error.getCause());
+ }
+ }
+
+ @Test
+ void customKindErrorsDescribeOnlyTheAcceptedKinds() throws Exception {
+ var cases =
+ Map.of(
+ Set.of(Kind.FLUID), "a FluidStack",
+ Set.of(Kind.TEXT), "a Component",
+ Set.of(Kind.TEXT_LIST), "a collection of Components",
+ Set.of(Kind.BOOLEAN, Kind.TEXT), "a boolean or a Component",
+ Set.of(Kind.WHOLE, Kind.FLUID), "a whole number or a FluidStack",
+ Set.of(Kind.DECIMAL), "a decimal number");
+ for (var entry : cases.entrySet()) {
+ var types = new WidgetTypes();
+ types.register(ID, new WidgetType(entry.getKey(), widget -> {}));
+ var getter = entry.getKey().contains(Kind.BOOLEAN) ? "#getProgress" : "#isAutoEject";
+ var screen = read(types, widget("value=\"" + getter + "\"", ""));
+ var error =
+ assertThrows(
+ ScreenException.class,
+ () -> ScreenBinder.bind(screen, getClass().getClassLoader(), types));
+ assertTrue(
+ error.getMessage().endsWith("this element needs " + entry.getValue()),
+ error.getMessage());
+ }
+ }
+
+ @Test
+ void rejectsWrongGetterKindsBeforeOpening() throws Exception {
+ var types = registry();
+ var screen = read(types, widget("value=\"#isAutoEject\" max=\"#getProgressMax\"", ""));
+ var error =
+ assertThrows(
+ ScreenException.class,
+ () -> ScreenBinder.bind(screen, getClass().getClassLoader(), types));
+ assertTrue(error.getMessage().contains("whole number"));
+ var badMax = read(types, widget("value=\"#getProgress\" max=\"#isAutoEject\"", ""));
+ assertThrows(
+ ScreenException.class, () -> ScreenBinder.bind(badMax, getClass().getClassLoader(), types));
+ }
+
+ @Test
+ void supportsDecorationsWithoutValuesAndForbidsUndeclaredBindings() throws Exception {
+ var types = new WidgetTypes();
+ types.register(ID, new WidgetType(Set.of(), widget -> {}));
+ var screen = read(types, widget("", ""));
+ assertTrue(ScreenBinder.bind(screen, getClass().getClassLoader(), types).values().isEmpty());
+ assertThrows(ScreenException.class, () -> read(types, widget(VALUES, "")));
+ assertThrows(ScreenException.class, () -> read(types, widget("max=\"#getProgressMax\"", "")));
+ }
+
+ @Test
+ void registriesRejectDuplicatesAndLateRegistration() {
+ var types = registry();
+ var type = types.require(ID);
+ assertThrows(IllegalArgumentException.class, () -> types.register(ID, type));
+ types.freeze();
+ assertSame(type, types.require(ID));
+ assertThrows(
+ IllegalStateException.class,
+ () -> types.register(ResourceLocation.parse("example:late"), type));
+ var renderers = new WidgetRenderers();
+ renderers.register(ID, context -> {});
+ assertThrows(IllegalArgumentException.class, () -> renderers.register(ID, context -> {}));
+ renderers.freeze(types);
+ assertThrows(
+ IllegalStateException.class,
+ () -> renderers.register(ResourceLocation.parse("example:late"), context -> {}));
+ }
+
+ @Test
+ void missingRenderersAndUnknownRendererTypesFailExplicitly() throws Exception {
+ var types = registry();
+ var screen = read(types, widget(VALUES, ""));
+ var renderers = new WidgetRenderers();
+ var error = assertThrows(ScreenException.class, () -> renderers.validate(screen));
+ assertTrue(error.getMessage().contains("custom.xml:3: Missing widget renderer: example:dial"));
+ renderers.register(ID, context -> {});
+ assertDoesNotThrow(() -> renderers.validate(screen));
+ assertThrows(IllegalArgumentException.class, () -> renderers.freeze(new WidgetTypes()));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java
index beb5378d..7389cef0 100644
--- a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java
+++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java
@@ -299,21 +299,35 @@ void statefulButtonsSelectIconsAndTranslatedValues() throws Exception {
require(button.labelFor(Boolean.TRUE).orElseThrow().key().equals("screen.test.on"));
require(button.iconFor(BigInteger.valueOf(-1)).orElseThrow().getPath().equals("off"));
require(!button.flat());
- fails("flat buttons require icons", HEAD
- + "