popLines(Element.Popout p) {
@@ -607,6 +655,8 @@ public void render(GuiGraphics g, int mx, int my, float partial) {
renderTooltip(g, mx, my);
if (hovered != null) tooltip(g, hovered, mx, my);
if (automation != null) automation.tooltip(g, mx, my);
+ containerDrawer.tooltip(g, mx, my);
+ curiosDrawer.tooltip(g, mx, my);
}
private void hover(Element e, boolean enter) {
@@ -696,6 +746,11 @@ private void send(Element e, int kind, int button, int option) {
@Override
public boolean mouseClicked(double mx, double my, int button) {
+ drawerMemory.userInput();
+ if (containerDrawer.click(mx, my, button) || curiosDrawer.click(mx, my, button)) {
+ widgetInput.clearFocus();
+ return true;
+ }
positionAutomation();
if (automation != null && automation.click(mx, my, button)) {
widgetInput.clearFocus();
@@ -723,12 +778,17 @@ public boolean mouseClicked(double mx, double my, int button) {
return true;
if (button > 1) continue;
if (e instanceof Element.Popout p) {
+ if (p.edge() == Element.Edge.LEFT) {
+ containerDrawer.close();
+ curiosDrawer.close();
+ }
if (automation != null && p.edge() == Element.Edge.RIGHT && menu.automation.open())
menu.automation.send("close", menu.automation.selected(), -1, "", 0, "");
if (!open.remove(p)) {
open.removeIf(other -> other.edge() == p.edge());
open.add(p);
}
+ drawerMemory.popoutIntent(p, open.contains(p));
return true;
}
if (e instanceof Element.Slot s && s.kind() == Element.SlotKind.GHOST) {
@@ -802,6 +862,18 @@ private void back(Element.Context c) {
@Override
public boolean keyPressed(int key, int scan, int modifiers) {
+ drawerMemory.userInput();
+ // A lost drawer acknowledgment must never trap the player in the screen.
+ if ((menu.containerDrawer.pending() || menu.curiosDrawer.pending())
+ && (key == 256
+ || net.minecraft.client.Minecraft.getInstance()
+ .options
+ .keyInventory
+ .matches(key, scan))) {
+ onClose();
+ return true;
+ }
+ if (containerDrawer.key(key, scan) || curiosDrawer.key(key, scan)) return true;
if (automation != null && automation.key(key, scan, modifiers)) return true;
updateWidgets();
if (widgetInput.keyPressed(key, scan, modifiers)) return true;
@@ -819,6 +891,9 @@ public boolean keyPressed(int key, int scan, int modifiers) {
@Override
public void removed() {
+ drawerMemory.capture(this, open);
+ containerDrawer.cancelGesture();
+ curiosDrawer.cancelGesture();
if (automation != null) automation.removed();
widgetInput.removed();
widgetsRemoved = true;
@@ -829,6 +904,7 @@ public void removed() {
@Override
public boolean mouseScrolled(double mx, double my, double dx, double dy) {
+ if (curiosDrawer.scroll(mx, my, dy) || containerDrawer.scroll(mx, my, dy)) return true;
if (automation != null && automation.scroll(mx, my, dy)) return true;
updateWidgets();
for (var p : open)
@@ -840,6 +916,7 @@ public boolean mouseScrolled(double mx, double my, double dx, double dy) {
}
private void updateWidgets() {
+ updateLayout();
widgetInput.update();
for (var e : menu.elements)
if (e instanceof Element.Custom custom) {
@@ -851,12 +928,21 @@ private void updateWidgets() {
@Override
protected void containerTick() {
super.containerTick();
+ drawerMemory.restore(this, open);
updateWidgets();
widgetInput.tick();
}
+ @Override
+ public void mouseMoved(double x, double y) {
+ containerDrawer.mouseMoved(x, y);
+ curiosDrawer.mouseMoved(x, y);
+ super.mouseMoved(x, y);
+ }
+
@Override
public boolean mouseDragged(double x, double y, int button, double dx, double dy) {
+ if (containerDrawer.drag(x, y, button) || curiosDrawer.drag(x, y, button)) return true;
if (automation != null && automation.drag(x, y, button, dx, dy)) return true;
updateWidgets();
return widgetInput.drag(x, y, button, dx, dy) || super.mouseDragged(x, y, button, dx, dy);
@@ -864,6 +950,8 @@ public boolean mouseDragged(double x, double y, int button, double dx, double dy
@Override
public boolean mouseReleased(double x, double y, int button) {
+ boolean curiosReleased = curiosDrawer.release(x, y, button);
+ if (containerDrawer.release(x, y, button) || curiosReleased) return true;
if (automation != null && automation.release(x, y, button)) return true;
updateWidgets();
return widgetInput.release(x, y, button) || super.mouseReleased(x, y, button);
@@ -885,6 +973,8 @@ public boolean keyReleased(int key, int scan, int modifiers) {
@Override
protected boolean hasClickedOutside(double x, double y, int left, int top, int button) {
return (automation == null || !automation.contains(x, y))
+ && !containerDrawer.contains(x, y)
+ && !curiosDrawer.contains(x, y)
&& super.hasClickedOutside(x, y, left, top, button);
}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java
index 9df5ff67..b898aaae 100644
--- a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java
+++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java
@@ -25,28 +25,35 @@ private static int color(PopoutKind kind) {
private static void sprite(GuiGraphics graphics, ResourceLocation sprite, Rect rect, int color) {
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
- graphics.setColor(((color >> 16) & 255) / 255f,
- ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1);
+ graphics.setColor(
+ ((color >> 16) & 255) / 255f, ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1);
graphics.blitSprite(sprite, rect.x(), rect.y(), rect.width(), rect.height());
graphics.setColor(1, 1, 1, 1);
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
}
public static void tab(GuiGraphics graphics, Rect tab, PopoutKind kind) {
- sprite(graphics, TAB, tab, color(kind));
+ tab(graphics, tab, color(kind));
+ }
+
+ public static void tab(GuiGraphics graphics, Rect tab, int color) {
+ sprite(graphics, TAB, tab, color);
}
public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, PopoutKind kind) {
+ open(graphics, tab, body, edge, color(kind));
+ }
+
+ public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, int color) {
int x = Math.min(tab.x(), body.x());
var whole = new Rect(x, body.y(), body.width() + tab.width(), body.height());
- int color = color(kind);
sprite(graphics, PANEL, whole, color);
// The narrow, darker rail retains the active icon beside the main machine panel.
int rail = 0xff000000 | (((color & 0xfefefe) >> 1) + ((color & 0xfcfcfc) >> 2));
- graphics.fill(tab.x() + 2, tab.bottom() - 1, tab.right() - 2, body.bottom() - 2, rail);
+ graphics.fill(tab.x() + 2, body.y() + 2, tab.right() - 2, body.bottom() - 2, rail);
int seam = edge == Edge.LEFT ? tab.x() : tab.right() - 1;
- graphics.fill(seam, tab.bottom() - 1, seam + 1, body.bottom() - 2,
- 0xff000000 | ((color & 0xfefefe) >> 1));
- tab(graphics, tab, kind);
+ graphics.fill(
+ seam, body.y() + 2, seam + 1, body.bottom() - 2, 0xff000000 | ((color & 0xfefefe) >> 1));
+ tab(graphics, tab, color);
}
}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java
new file mode 100644
index 00000000..6a2b2caf
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java
@@ -0,0 +1,71 @@
+package zone.moddev.mc.lib.gui.layout;
+
+/** Six-column left drawer filling the shared inset panel height, with a tab beside the hotbar. */
+public record ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows, boolean equipment) {
+ private static final int BODY_WIDTH = 6 * 18 + 12;
+
+ public ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows) {
+ this(tab, body, columns, rows, false);
+ }
+
+ public static ContainerDrawerLayout equipment(
+ Rect panel, int bottom, int slots, boolean backpack) {
+ var base = of(panel, bottom, slots);
+ var tab = base.tab().offset(0, backpack ? -24 : 0);
+ var body = base.body();
+ int height = body.height();
+ int rows = Math.min(27, Math.max(0, (height - 56) / 22));
+ if (tab.x() - BODY_WIDTH < 4 || rows == 0)
+ return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0, true);
+ rows = Math.min(rows, Math.max(1, (slots + 1) / 2));
+ return new ContainerDrawerLayout(tab, body, 2, rows, true);
+ }
+
+ public static ContainerDrawerLayout of(Rect panel, int bottom, int slots) {
+ var tab =
+ new Rect(
+ Math.max(0, panel.x() - 22),
+ Math.max(SideDrawerLayout.top(panel), SideDrawerLayout.bottom(panel, bottom) - 24),
+ 22,
+ 24);
+ int columns = 6;
+ int width = BODY_WIDTH;
+ var body =
+ SideDrawerLayout.body(
+ panel, bottom, tab.x() - width, SideDrawerLayout.top(panel), width, Integer.MAX_VALUE);
+ int height = body.height();
+ int rows = Math.min(9, Math.max(0, (height - 44) / 18));
+ if (tab.x() - width < 4 || rows == 0)
+ return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0);
+ rows = Math.min(rows, Math.max(1, (slots + columns - 1) / columns));
+ return new ContainerDrawerLayout(tab, body, columns, rows);
+ }
+
+ public int pageSize() {
+ return columns * rows;
+ }
+
+ public Rect rail() {
+ return SideDrawerLayout.rail(tab, body);
+ }
+
+ public Rect cell(int visible) {
+ if (equipment)
+ return new Rect(
+ body.right() - 44 + visible % 2 * 20, body.y() + 34 + visible / 2 * 22, 18, 18);
+ return new Rect(
+ body.x() + 6 + visible % columns * 18, body.y() + 22 + visible / columns * 18, 18, 18);
+ }
+
+ public Rect previous() {
+ return new Rect(body.x() + 3, body.bottom() - 18, 10, 14);
+ }
+
+ public Rect next() {
+ return new Rect(body.right() - 13, body.bottom() - 18, 10, 14);
+ }
+
+ public Rect selection() {
+ return new Rect(body.x() + 4, body.y() + 4, body.width() - 8, 16);
+ }
+}
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 29856cc1..d0a7b5b0 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
@@ -16,17 +16,31 @@
* The main panel stacks the screen's top-level elements, then (unless the screen places it or
* leaves it out) the player inventory, centred. Boxes stack their children; inside a {@code vbox} a
* child {@code hbox} stretches to the full width, and inside an {@code hbox} a child {@code vbox}
- * stretches to the full height, so {@code spacer}s have room to fill. A context area is as large as
- * its largest view, and every view is placed at the same spot. Side tabs and pop-out tabs stack
- * down their panel edge, side tabs first.
+ * stretches to the full height, so {@code spacer}s have room to fill. A context area sizes itself
+ * to its selected view and fills the available width in vertical layouts. Side tabs and pop-out
+ * tabs stack down their panel edge, side tabs first.
*
*
Fixed sizes come from the sprites ({@code docs/gui-sprites.md}): slots 18 px (26 large),
* gauges 18 px thick, progress arrows 24x16, checkboxes and radios 10 px, buttons 20 px high,
* side-configuration faces 20 px in a 3x3 grid.
*/
public final class ScreenLayout {
- /** Width and height of the player inventory: three rows and the hotbar, plus its title. */
- public static final int INVENTORY_WIDTH = 162, INVENTORY_HEIGHT = 11 + 3 * 18 + 4 + 18;
+ /** Three inventory rows and a hotbar, with a toolbar and equipment column on the right. */
+ public static final int INVENTORY_WIDTH = 162 + 4 + 18, INVENTORY_HEIGHT = 18 + 3 * 18 + 4 + 18;
+
+ /** Slot background for a vanilla player inventory index (0..40), relative to the panel. */
+ public static Rect playerSlot(Rect inventory, int index) {
+ if (index < 0 || index > 40) throw new IllegalArgumentException("Player slot: " + index);
+ int x, y;
+ if (index < 36) {
+ x = (index % 9) * 18;
+ y = index < 9 ? 76 : 18 + (index / 9 - 1) * 18;
+ } else {
+ x = 166;
+ y = index == 40 ? 76 : (39 - index) * 18;
+ }
+ return new Rect(inventory.x() + x, inventory.y() + y, 18, 18);
+ }
/** Margin around the panel's content, as vanilla screens. */
public static final int PANEL_MARGIN = 7;
@@ -35,7 +49,7 @@ public final class ScreenLayout {
public static final int GAP = 4;
/** Space around the content of a side tab, and the tab's overlap with the panel. */
- public static final int TAB_PADDING = 4, TAB_OVERLAP = 4, TAB_BORDER = 3;
+ public static final int TAB_PADDING = SideDrawerLayout.MARGIN, TAB_OVERLAP = 4, TAB_BORDER = 3;
/** Width of a pop-out edge tab: a 16 px icon with a 3 px border. */
public static final int POPOUT_TAB = 22;
@@ -56,12 +70,14 @@ public final class ScreenLayout {
private final java.util.Set alternateViews =
java.util.Collections.newSetFromMap(new IdentityHashMap<>());
private final TextMeasure text;
+ private final Map views;
private Rect panel;
private Rect inventory;
private Rect inventoryColors;
- private ScreenLayout(TextMeasure text) {
+ private ScreenLayout(TextMeasure text, Map views) {
this.text = text;
+ this.views = Map.copyOf(views);
}
/**
@@ -73,9 +89,15 @@ public static ScreenLayout of(Screen screen, TextMeasure text) {
return of(screen, text, false);
}
- /** Reserves an actual row above the player inventory for resource-group selectors. */
+ /** Places the inventory toolbar beside the helmet, directly above the storage slots. */
public static ScreenLayout of(Screen screen, TextMeasure text, boolean automation) {
- var layout = new ScreenLayout(text);
+ return of(screen, text, automation, Map.of());
+ }
+
+ /** Measures selected context views; omitted selections use each context's default view. */
+ public static ScreenLayout of(
+ Screen screen, TextMeasure text, boolean automation, Map views) {
+ var layout = new ScreenLayout(text, views);
layout.place(screen);
var controls =
screen.body().stream()
@@ -92,22 +114,23 @@ public static ScreenLayout of(Screen screen, TextMeasure text, boolean automatio
Math.max(
AutomationLayout.SELECTOR_ROW_HEIGHT,
controls == null ? 0 : layout.size(controls)[1] + GAP);
- int colourWidth = layout.inventory.width() - (controlsWidth == 0 ? 0 : controlsWidth + GAP);
+ int storageRight = playerSlot(layout.inventory, 17).right();
+ int colourWidth =
+ storageRight - layout.inventory.x() - (controlsWidth == 0 ? 0 : controlsWidth + GAP);
if (colourWidth < (automation ? 34 : 0))
throw new IllegalArgumentException(
"Inventory controls leave too little room for colour selectors");
+ // The inventory already reserves one slot row beside the helmet. Only taller controls
+ // need more space; ordinary flat controls and colour selectors use that existing row.
+ int extraHeight = Math.max(0, rowHeight - 18);
layout.bounds.replaceAll(
- (element, rect) -> rect.y() >= start ? rect.offset(0, rowHeight) : rect);
+ (element, rect) -> rect.y() >= start ? rect.offset(0, extraHeight) : rect);
layout.inventoryColors = new Rect(layout.inventory.x(), start, colourWidth, rowHeight - GAP);
if (controls != null)
layout.placeAt(
- controls,
- layout.inventory.right() - controlsWidth,
- start,
- controlsWidth,
- rowHeight - GAP);
- layout.inventory = layout.inventory.offset(0, rowHeight);
- layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + rowHeight);
+ controls, storageRight - controlsWidth, start, controlsWidth, rowHeight - GAP);
+ layout.inventory = layout.inventory.offset(0, extraHeight);
+ layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + extraHeight);
}
if (automation) {
layout.bounds.replaceAll(
@@ -125,7 +148,7 @@ public Rect panel() {
return panel;
}
- /** Where the player inventory's title starts, if the screen shows the inventory. */
+ /** Bounds of the player inventory, including the toolbar row and equipment column. */
public Optional inventory() {
return Optional.ofNullable(inventory);
}
@@ -200,15 +223,7 @@ private int[] size(Element element) {
Math.max(inner[0], group.label().map(text::width).orElse(0)), inner[1] + label
};
}
- case Context context -> {
- int width = 0, height = 0;
- for (var view : context.views()) {
- int[] size = size(view);
- width = Math.max(width, size[0]);
- height = Math.max(height, size[1]);
- }
- yield new int[] {width, height};
- }
+ case Context context -> size(selectedView(context));
case View view -> {
int[] inner = stack(view.children(), GAP);
int title = viewHeader(view);
@@ -273,6 +288,14 @@ private int viewHeader(View view) {
: 0;
}
+ private View selectedView(Context context) {
+ String selected = views.get(context.common().id().orElse(""));
+ return context.views().stream()
+ .filter(view -> view.common().id().orElse("").equals(selected))
+ .findFirst()
+ .orElse(context.defaultView());
+ }
+
private void place(Screen screen) {
screen.forEach(
e -> {
@@ -294,8 +317,9 @@ private void place(Screen screen) {
int y = titleHeight;
for (var element : content) {
int[] size = size(element);
- // Top-level boxes span the panel, as a vbox's children do.
- int w = element instanceof Box || fills(element) ? width : size[0];
+ // Top-level boxes and context areas span the panel, as a vbox's children do.
+ int w =
+ element instanceof Box || element instanceof Context || fills(element) ? width : size[0];
placeAt(element, 0, y, w, size[1]);
y += size[1] + GAP;
}
@@ -335,7 +359,16 @@ private void placeAt(Element element, int x, int y, int width, int height) {
placeStack(group.children(), x, top, width, 2);
}
case Context context -> {
- for (var view : context.views()) placeAt(view, x, y, width, height);
+ for (var view : context.views()) {
+ // Hidden elements still need bounds for their persistent menu slots and widgets.
+ int[] size = size(view);
+ placeAt(
+ view,
+ x,
+ y,
+ Math.max(width, size[0]),
+ view == selectedView(context) ? height : size[1]);
+ }
}
case View view -> {
int top = y + (viewHeader(view));
@@ -358,7 +391,10 @@ private void placeAt(Element element, int x, int y, int width, int height) {
private void placeStack(List children, int x, int y, int width, int spacing) {
for (var child : children) {
int[] size = size(child);
- int w = (child instanceof Box box && !box.vertical()) || fills(child) ? width : size[0];
+ int w =
+ (child instanceof Box box && !box.vertical()) || child instanceof Context || fills(child)
+ ? width
+ : size[0];
placeAt(child, x, y, w, size[1]);
y += size[1] + spacing;
}
@@ -388,7 +424,8 @@ private void placeBox(Box box, int x, int y, int width, int height) {
main = leftover / spacers + (spacerIndex++ < leftover % spacers ? 1 : 0);
}
if (box.vertical()) {
- boolean stretch = (child instanceof Box b && !b.vertical()) || fills(child);
+ boolean stretch =
+ (child instanceof Box b && !b.vertical()) || child instanceof Context || fills(child);
int w = stretch ? innerW : size[0];
placeAt(child, innerX + align(box.align(), innerW, w), position, w, main);
} else {
diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java
new file mode 100644
index 00000000..c03514c2
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java
@@ -0,0 +1,41 @@
+package zone.moddev.mc.lib.gui.layout;
+
+/** Shared panel insets and bounds for drawers with independent content and tab placement. */
+public final class SideDrawerLayout {
+ public static final int MARGIN = 4;
+
+ /** Content drawers can grow to the viewport; inventory drawers stay aligned with the panel. */
+ public enum HeightLimit {
+ PANEL,
+ SCREEN
+ }
+
+ private SideDrawerLayout() {}
+
+ public static int top(Rect panel) {
+ return Math.max(MARGIN, panel.y() + MARGIN);
+ }
+
+ /** The screen/overlay limit already includes its own margin. */
+ public static int bottom(Rect panel, int screenBottom) {
+ return Math.min(panel.bottom() - MARGIN, screenBottom);
+ }
+
+ /** Fits a content-sized or full-height body between the shared panel and screen limits. */
+ public static Rect body(
+ Rect panel, int screenBottom, int x, int y, int width, int desiredHeight) {
+ return body(panel, screenBottom, x, y, width, desiredHeight, HeightLimit.PANEL);
+ }
+
+ public static Rect body(
+ Rect panel, int screenBottom, int x, int y, int width, int desiredHeight, HeightLimit limit) {
+ int top = Math.max(top(panel), y);
+ int bottom = limit == HeightLimit.SCREEN ? screenBottom : bottom(panel, screenBottom);
+ int available = Math.max(0, bottom - top);
+ return new Rect(x, top, width, Math.min(Math.max(0, desiredHeight), available));
+ }
+
+ public static Rect rail(Rect tab, Rect body) {
+ return new Rect(tab.x(), body.y(), tab.width(), body.height());
+ }
+}
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 b822e026..ff22b0fe 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
@@ -57,7 +57,19 @@ public Optional find(String id) {
/** Stable model fingerprint used to reject differing client/server screen definitions. */
public String fingerprint() {
String model =
- id + "|" + title + "|" + blockEntity + "|" + playerInventory + "|" + body + "|" + tabs;
+ // The implicit equipment slots also change menu numbering, even with identical XML.
+ "equipment-v1|"
+ + id
+ + "|"
+ + title
+ + "|"
+ + blockEntity
+ + "|"
+ + playerInventory
+ + "|"
+ + body
+ + "|"
+ + tabs;
try {
return java.util.HexFormat.of()
.formatHex(
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java
new file mode 100644
index 00000000..67f58d3a
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java
@@ -0,0 +1,539 @@
+package zone.moddev.mc.lib.gui.runtime;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.neoforge.items.IItemHandler;
+import net.neoforged.neoforge.network.PacketDistributor;
+
+/** Per-viewer, server-authoritative access to an equipped container's generic I/O capability. */
+public final class ContainerDrawerMenu {
+ public static final int MAX_CONTAINERS = 16, MAX_SLOTS = 4096, MAX_PAGE = 54;
+ public static final int TOGGLE = 0,
+ SELECT = 1,
+ PAGE = 2,
+ PICKUP = 3,
+ QUICK_TAKE = 4,
+ QUICK_STORE = 5,
+ SYNC = 6,
+ DISTRIBUTE = 7,
+ COLLECT = 8,
+ SWAP = 9;
+
+ private final MachineMenu menu;
+ private final boolean equipment;
+ private final Player owner;
+ private final Supplier> discover;
+ private List sources = List.of();
+ private List sourceSizes = List.of();
+ private int selected,
+ offset,
+ pageSize = 27,
+ lastRequest,
+ nextRequest,
+ pendingRequest,
+ pendingTicks;
+ private boolean open, pendingInventory;
+ private long revision, actionTick = Long.MIN_VALUE;
+ private int actions;
+ private ContainerDrawerNetwork.State state;
+ private boolean dirty;
+
+ public ContainerDrawerMenu(MachineMenu menu, Player owner) {
+ this(
+ menu,
+ owner,
+ () -> menu.bound.screen().playerInventory() ? EquippedContainers.find(owner) : List.of());
+ }
+
+ /** A discovery seam for runtime tests and alternative equipment providers. */
+ public ContainerDrawerMenu(
+ MachineMenu menu, Player owner, Supplier> discover) {
+ this(menu, owner, discover, false);
+ }
+
+ public ContainerDrawerMenu(
+ MachineMenu menu,
+ Player owner,
+ Supplier> discover,
+ boolean equipment) {
+ this.equipment = equipment;
+ if (equipment) pageSize = 20;
+ this.menu = menu;
+ this.owner = owner;
+ this.discover = discover;
+ state =
+ new ContainerDrawerNetwork.State(
+ menu.containerId,
+ equipment,
+ menu.widgetSession(),
+ 0,
+ 0,
+ false,
+ 0,
+ 0,
+ pageSize,
+ List.of(),
+ List.of(),
+ List.of(),
+ List.of(),
+ List.of());
+ }
+
+ public ContainerDrawerNetwork.State state() {
+ return state;
+ }
+
+ public boolean pending() {
+ return pendingRequest != 0;
+ }
+
+ /** Only item-changing requests need to serialize vanilla inventory gestures. */
+ public boolean pendingInventory() {
+ return pending() && pendingInventory;
+ }
+
+ public Optional request(int kind, int argument, int size) {
+ return request(kind, argument, size, List.of());
+ }
+
+ public Optional request(
+ int kind, int argument, int size, List slots) {
+ if (pending() || nextRequest == Integer.MAX_VALUE) return Optional.empty();
+ pendingInventory |=
+ kind == PICKUP
+ || kind == QUICK_TAKE
+ || kind == QUICK_STORE
+ || kind == DISTRIBUTE
+ || kind == COLLECT
+ || kind == SWAP;
+ pendingRequest = ++nextRequest;
+ pendingTicks = 0;
+ return Optional.of(
+ new ContainerDrawerNetwork.Request(
+ menu.containerId,
+ equipment,
+ menu.widgetSession(),
+ state.revision(),
+ pendingRequest,
+ kind,
+ argument,
+ size,
+ menu.getCarried().copy(),
+ slots));
+ }
+
+ public void receive(ContainerDrawerNetwork.State update) {
+ if (update.equipment() != equipment
+ || update.menu() != menu.containerId
+ || update.session() != menu.widgetSession()
+ || update.revision() < state.revision()) return;
+ state = update;
+ nextRequest = Math.max(nextRequest, update.acknowledged());
+ if (update.acknowledged() >= pendingRequest) {
+ pendingRequest = 0;
+ pendingInventory = false;
+ }
+ }
+
+ public void tickClient() {
+ if (pending() && ++pendingTicks >= 20) {
+ pendingRequest = 0;
+ request(SYNC, 0, state.pageSize()).ifPresent(PacketDistributor::sendToServer);
+ }
+ }
+
+ /** Re-resolves equipment and capabilities; replacing a stack invalidates the open session. */
+ public void refresh() {
+ var found =
+ discover.get().stream()
+ .limit(MAX_CONTAINERS)
+ .filter(
+ s ->
+ !s.stack().isEmpty()
+ && s.items().getSlots() > 0
+ && s.items().getSlots() <= MAX_SLOTS)
+ .toList();
+ boolean replaced = found.size() != sources.size();
+ if (!replaced)
+ for (int i = 0; i < found.size(); i++) {
+ if (!found.get(i).key().equals(sources.get(i).key())
+ || found.get(i).stack() != sources.get(i).stack()
+ || found.get(i).items().getSlots() != sourceSizes.get(i)) {
+ replaced = true;
+ break;
+ }
+ }
+ // Capabilities may be replaced without replacing the equipped stack. Always use the live one.
+ sources = found;
+ if (replaced) {
+ sourceSizes = found.stream().map(s -> s.items().getSlots()).toList();
+ open = false;
+ offset = 0;
+ selected = 0;
+ revision++;
+ dirty = true;
+ }
+ boolean iconsChanged = found.size() != state.icons().size();
+ if (!iconsChanged)
+ for (int i = 0; i < found.size(); i++)
+ if (!ItemStack.isSameItemSameComponents(found.get(i).stack(), state.icons().get(i))) {
+ iconsChanged = true;
+ break;
+ }
+ IItemHandler items = open && !found.isEmpty() ? found.get(selected).items() : null;
+ if (items != null) offset = Math.clamp(offset, 0, Math.max(0, items.getSlots() - 1));
+ int count = items == null ? 0 : Math.min(pageSize, items.getSlots() - offset);
+ var described = items instanceof EquippedContainers.DescribedHandler d ? d : null;
+ boolean contentsChanged = count != state.contents().size();
+ boolean descriptionsChanged = (described == null ? 0 : count) != state.descriptions().size();
+ for (int i = 0; i < count; i++) {
+ if (!contentsChanged
+ && !ItemStack.matches(items.getStackInSlot(offset + i), state.contents().get(i)))
+ contentsChanged = true;
+ if (!descriptionsChanged
+ && described != null
+ && !described.describe(offset + i).equals(state.descriptions().get(i)))
+ descriptionsChanged = true;
+ }
+ if (iconsChanged
+ || contentsChanged
+ || descriptionsChanged
+ || open != state.open()
+ || selected != state.selected()
+ || offset != state.offset()
+ || pageSize != state.pageSize()) {
+ revision++;
+ dirty = true;
+ }
+ // Scan live handlers, but copy immutable packet data only when something actually changed.
+ if (revision == state.revision() && lastRequest == state.acknowledged()) return;
+ var icons =
+ iconsChanged ? found.stream().map(s -> s.stack().copyWithCount(1)).toList() : state.icons();
+ List contents = state.contents();
+ if (contentsChanged) {
+ contents = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) contents.add(items.getStackInSlot(offset + i).copy());
+ }
+ List descriptions = state.descriptions();
+ if (descriptionsChanged) {
+ descriptions = new ArrayList<>();
+ if (described != null)
+ for (int i = 0; i < count; i++) descriptions.add(described.describe(offset + i));
+ }
+ state =
+ new ContainerDrawerNetwork.State(
+ menu.containerId,
+ equipment,
+ menu.widgetSession(),
+ revision,
+ lastRequest,
+ open,
+ selected,
+ offset,
+ pageSize,
+ icons,
+ replaced
+ ? found.stream().map(s -> equipment ? "curios" : s.key()).toList()
+ : state.keys(),
+ sourceSizes,
+ contents,
+ descriptions);
+ }
+
+ public void broadcast() {
+ if (owner.level().isClientSide) return;
+ refresh();
+ if (dirty
+ && owner instanceof ServerPlayer player
+ && player.connection.hasChannel(ContainerDrawerNetwork.State.TYPE)) {
+ PacketDistributor.sendToPlayer(player, state);
+ dirty = false;
+ }
+ }
+
+ public void action(Player player, ContainerDrawerNetwork.Request request) {
+ if (request.equipment() != equipment
+ || player != owner
+ || player.containerMenu != menu
+ || !menu.stillValid(player)
+ || request.menu() != menu.containerId
+ || request.session() != menu.widgetSession()
+ || request.request() <= lastRequest) return;
+ lastRequest = request.request();
+ refresh();
+ long tick = player.level().getGameTime();
+ if (tick != actionTick) {
+ actionTick = tick;
+ actions = 0;
+ }
+ if (++actions <= 32 && request.revision() == revision) {
+ if (request.kind() == TOGGLE && !sources.isEmpty()) {
+ open = !open;
+ if (open) (equipment ? menu.containerDrawer : menu.curiosDrawer).closeServer();
+ } else if (open && !sources.isEmpty()) {
+ var source = sources.get(selected);
+ int argument = request.argument();
+ switch (request.kind()) {
+ case SELECT -> {
+ if (argument >= 0 && argument < sources.size()) {
+ selected = argument;
+ offset = 0;
+ }
+ }
+ case PAGE -> {
+ if (request.pageSize() >= 1
+ && request.pageSize() <= MAX_PAGE
+ && (!equipment || request.pageSize() % 2 == 0 && argument % 2 == 0)
+ && argument >= 0
+ && argument < source.items().getSlots()) {
+ pageSize = request.pageSize();
+ offset = argument;
+ }
+ }
+ case PICKUP, QUICK_TAKE -> {
+ int slot = argument / 2;
+ if (argument >= 0
+ && slot >= offset
+ && slot < Math.min(offset + pageSize, source.items().getSlots())
+ && ItemStack.matches(request.carried(), menu.getCarried())) {
+ if (request.kind() == QUICK_TAKE) takeToPlayer(source.items(), slot);
+ else click(source, slot, argument % 2 == 1);
+ }
+ }
+ case QUICK_STORE -> {
+ if (argument >= 0
+ && argument < 36
+ && ItemStack.matches(request.carried(), menu.getCarried()))
+ storeFromPlayer(source, argument);
+ }
+ case DISTRIBUTE -> {
+ if (!equipment
+ && argument <= 1
+ && validTargets(request.slots(), source)
+ && ItemStack.matches(request.carried(), menu.getCarried()))
+ distribute(source, request.slots(), argument == 1);
+ }
+ case COLLECT -> {
+ if (!equipment
+ && visibleSlot(argument, source)
+ && ItemStack.matches(request.carried(), menu.getCarried())) collect(source);
+ }
+ case SWAP -> {
+ if (!equipment
+ && argument < 9
+ && request.slots().size() == 1
+ && visibleSlot(request.slots().getFirst(), source)
+ && ItemStack.matches(request.carried(), menu.getCarried()))
+ swap(source, request.slots().getFirst(), argument);
+ }
+ default -> {}
+ }
+ }
+ }
+ revision++;
+ dirty = true;
+ // Vanilla slot/cursor changes precede the acknowledgment, so pending input can resume safely.
+ if (request.kind() == SYNC) menu.sendAllDataToRemote();
+ menu.broadcastChanges();
+ broadcast();
+ }
+
+ public void closeServer() {
+ if (open) {
+ open = false;
+ revision++;
+ dirty = true;
+ }
+ }
+
+ private boolean visibleSlot(int slot, EquippedContainers.Source source) {
+ return slot >= offset && slot < Math.min(offset + pageSize, source.items().getSlots());
+ }
+
+ private boolean validTargets(List slots, EquippedContainers.Source source) {
+ return !slots.isEmpty()
+ && slots.stream().distinct().count() == slots.size()
+ && slots.stream()
+ .allMatch(i -> visibleSlot(i, source) || i >= MAX_SLOTS && i < MAX_SLOTS + 36);
+ }
+
+ private void distribute(EquippedContainers.Source source, List slots, boolean right) {
+ var carried = menu.getCarried();
+ if (carried.isEmpty() || ItemStack.isSameItemSameComponents(carried, source.stack())) return;
+ var inventory = new net.neoforged.neoforge.items.wrapper.InvWrapper(owner.getInventory());
+ var eligible =
+ slots.stream()
+ .filter(
+ i -> {
+ var handler = i >= MAX_SLOTS ? inventory : source.items();
+ int slot = i >= MAX_SLOTS ? i - MAX_SLOTS : i;
+ return handler.insertItem(slot, carried.copyWithCount(1), true).isEmpty();
+ })
+ .toList();
+ if (eligible.isEmpty()) return;
+ int amount = right ? 1 : carried.getCount() / eligible.size();
+ if (amount == 0) return;
+ var remaining = carried.copy();
+ for (int i : eligible) {
+ var handler = i >= MAX_SLOTS ? inventory : source.items();
+ int slot = i >= MAX_SLOTS ? i - MAX_SLOTS : i;
+ int count = Math.min(amount, remaining.getCount());
+ var rest = handler.insertItem(slot, remaining.copyWithCount(count), false);
+ remaining.shrink(count - rest.getCount());
+ if (remaining.isEmpty()) break;
+ }
+ menu.setCarried(remaining);
+ }
+
+ private void collect(EquippedContainers.Source source) {
+ var carried = menu.getCarried().copy();
+ if (carried.isEmpty()) return;
+ var inventory = new net.neoforged.neoforge.items.wrapper.InvWrapper(owner.getInventory());
+ // Vanilla collects partial stacks first. Include all storage pages and ordinary player slots.
+ for (int pass = 0; pass < 2; pass++) {
+ for (var handler : List.of(source.items(), inventory)) {
+ int count = handler == inventory ? 36 : handler.getSlots();
+ for (int slot = 0; slot < count && carried.getCount() < carried.getMaxStackSize(); slot++) {
+ var stored = handler.getStackInSlot(slot);
+ if (!ItemStack.isSameItemSameComponents(stored, carried)
+ || pass == 0 && stored.getCount() >= stored.getMaxStackSize()) continue;
+ var taken =
+ handler.extractItem(slot, carried.getMaxStackSize() - carried.getCount(), false);
+ if (ItemStack.isSameItemSameComponents(taken, carried)) carried.grow(taken.getCount());
+ else recover(handler, slot, taken);
+ }
+ }
+ }
+ menu.setCarried(carried);
+ }
+
+ private void swap(EquippedContainers.Source source, int slot, int hotbar) {
+ var handler = source.items();
+ var incoming = owner.getInventory().getItem(hotbar).copy();
+ var outgoing = handler.getStackInSlot(slot).copy();
+ if (!incoming.isEmpty()
+ && (ItemStack.isSameItemSameComponents(incoming, source.stack())
+ || !handler.isItemValid(slot, incoming)
+ || incoming.getCount()
+ > Math.min(handler.getSlotLimit(slot), incoming.getMaxStackSize()))) return;
+ if (!outgoing.isEmpty()
+ && (outgoing.getCount() > outgoing.getMaxStackSize()
+ || !ItemStack.matches(handler.extractItem(slot, outgoing.getCount(), true), outgoing)))
+ return;
+ var taken = handler.extractItem(slot, outgoing.getCount(), false);
+ if (!ItemStack.matches(taken, outgoing)) {
+ recover(handler, slot, taken);
+ return;
+ }
+ owner.getInventory().setItem(hotbar, ItemStack.EMPTY);
+ var remainder =
+ incoming.isEmpty() ? ItemStack.EMPTY : handler.insertItem(slot, incoming, false);
+ if (!incoming.isEmpty() && ItemStack.matches(remainder, incoming)) {
+ owner.getInventory().setItem(hotbar, incoming);
+ recover(handler, slot, taken);
+ } else {
+ owner.getInventory().setItem(hotbar, remainder.isEmpty() ? taken : remainder);
+ if (!remainder.isEmpty()) giveToPlayer(taken);
+ }
+ owner.getInventory().setChanged();
+ }
+
+ private void recover(IItemHandler handler, int slot, ItemStack stack) {
+ if (!stack.isEmpty()) giveToPlayer(handler.insertItem(slot, stack, false));
+ }
+
+ private void giveToPlayer(ItemStack stack) {
+ insertIntoPlayer(stack);
+ if (!stack.isEmpty()) owner.drop(stack, false);
+ }
+
+ private void insertIntoPlayer(ItemStack stack) {
+ // Inventory.add can discard overflow in creative mode or merge into the offhand.
+ // Recovery must preserve every item and use only ordinary player storage.
+ for (int pass = 0; pass < 2; pass++)
+ for (int i = 0; i < 36 && !stack.isEmpty(); i++) {
+ var existing = owner.getInventory().getItem(i);
+ if (!(pass == 0 ? ItemStack.isSameItemSameComponents(existing, stack) : existing.isEmpty()))
+ continue;
+ int room =
+ Math.max(
+ 0,
+ Math.min(owner.getInventory().getMaxStackSize(), stack.getMaxStackSize())
+ - existing.getCount());
+ int amount = Math.min(room, stack.getCount());
+ if (amount > 0) {
+ owner.getInventory().setItem(i, stack.copyWithCount(existing.getCount() + amount));
+ stack.shrink(amount);
+ }
+ }
+ owner.getInventory().setChanged();
+ }
+
+ private void click(EquippedContainers.Source source, int slot, boolean right) {
+ var handler = source.items();
+ var carried = menu.getCarried();
+ if (carried.isEmpty()) {
+ var shown = handler.getStackInSlot(slot);
+ if (shown.isEmpty()) return;
+ int amount =
+ Math.min(
+ shown.getMaxStackSize(),
+ right ? shown.getCount() / 2 + shown.getCount() % 2 : shown.getCount());
+ menu.setCarried(handler.extractItem(slot, amount, false));
+ } else if (equipment || !ItemStack.isSameItemSameComponents(carried, source.stack())) {
+ int amount = right ? 1 : carried.getCount();
+ var remainder = handler.insertItem(slot, carried.copyWithCount(amount), false);
+ int inserted = amount - remainder.getCount();
+ if (inserted > 0) menu.setCarried(carried.copyWithCount(carried.getCount() - inserted));
+ }
+ }
+
+ private void storeFromPlayer(EquippedContainers.Source source, int index) {
+ var original = owner.getInventory().getItem(index);
+ if (original.isEmpty()
+ || !equipment && ItemStack.isSameItemSameComponents(original, source.stack())) return;
+ var remaining = original.copy();
+ for (int pass = 0; pass < 2; pass++)
+ for (int slot = 0; slot < source.items().getSlots() && !remaining.isEmpty(); slot++) {
+ if (source.items() instanceof EquippedContainers.DescribedHandler described
+ && !described.quickInsert(slot)) continue;
+ var stored = source.items().getStackInSlot(slot);
+ if (pass == 0 ? ItemStack.isSameItemSameComponents(stored, remaining) : stored.isEmpty())
+ remaining = source.items().insertItem(slot, remaining, false);
+ }
+ owner.getInventory().setItem(index, remaining);
+ owner.getInventory().setChanged();
+ }
+
+ private void takeToPlayer(IItemHandler handler, int slot) {
+ var shown = handler.getStackInSlot(slot);
+ if (shown.isEmpty()) return;
+ var simulated = handler.extractItem(slot, shown.getMaxStackSize(), true);
+ if (simulated.isEmpty()) return;
+ int room = 0;
+ for (int i = 0; i < 36; i++) {
+ var existing = owner.getInventory().getItem(i);
+ if (existing.isEmpty() || ItemStack.isSameItemSameComponents(existing, simulated))
+ room +=
+ Math.max(
+ 0,
+ Math.min(owner.getInventory().getMaxStackSize(), simulated.getMaxStackSize())
+ - existing.getCount());
+ }
+ if (room == 0) return;
+ var taken = handler.extractItem(slot, Math.min(room, simulated.getCount()), false);
+ insertIntoPlayer(taken);
+ // Preserve items even if a third-party extraction callback unexpectedly changed the
+ // destination.
+ if (!taken.isEmpty()) {
+ var leftover = handler.insertItem(slot, taken, false);
+ if (!leftover.isEmpty()) owner.drop(leftover, false);
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java
new file mode 100644
index 00000000..07b95133
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java
@@ -0,0 +1,262 @@
+package zone.moddev.mc.lib.gui.runtime;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.neoforge.network.registration.PayloadRegistrar;
+
+/** Bounded snapshots and session/revision-bound requests for generic equipped containers. */
+public final class ContainerDrawerNetwork {
+ private ContainerDrawerNetwork() {}
+
+ public record Request(
+ int menu,
+ boolean equipment,
+ long session,
+ long revision,
+ int request,
+ int kind,
+ int argument,
+ int pageSize,
+ ItemStack carried,
+ List slots)
+ implements CustomPacketPayload {
+ public Request(
+ int menu,
+ long session,
+ long revision,
+ int request,
+ int kind,
+ int argument,
+ int pageSize,
+ ItemStack carried) {
+ this(menu, false, session, revision, request, kind, argument, pageSize, carried, List.of());
+ }
+
+ public Request(
+ int menu,
+ boolean equipment,
+ long session,
+ long revision,
+ int request,
+ int kind,
+ int argument,
+ int pageSize,
+ ItemStack carried) {
+ this(
+ menu, equipment, session, revision, request, kind, argument, pageSize, carried,
+ List.of());
+ }
+
+ public Request {
+ slots = List.copyOf(slots);
+ if (revision < 0
+ || request < 1
+ || kind < 0
+ || kind > ContainerDrawerMenu.SWAP
+ || argument < 0
+ || argument >= ContainerDrawerMenu.MAX_SLOTS * 2
+ || pageSize < 1
+ || pageSize > ContainerDrawerMenu.MAX_PAGE
+ || slots.size() > ContainerDrawerMenu.MAX_PAGE + 36
+ || slots.stream().anyMatch(n -> n < 0 || n >= ContainerDrawerMenu.MAX_SLOTS + 36))
+ throw new IllegalArgumentException("Invalid container drawer request");
+ }
+
+ public static final Type TYPE =
+ new Type<>(ResourceLocation.parse("mmdlib:container_action"));
+ public static final StreamCodec CODEC =
+ StreamCodec.of(
+ (b, p) -> {
+ b.writeVarInt(p.menu);
+ b.writeBoolean(p.equipment);
+ b.writeLong(p.session);
+ b.writeVarLong(p.revision);
+ b.writeVarInt(p.request);
+ b.writeVarInt(p.kind);
+ b.writeVarInt(p.argument);
+ b.writeVarInt(p.pageSize);
+ ItemStack.OPTIONAL_STREAM_CODEC.encode(b, p.carried);
+ b.writeVarInt(p.slots.size());
+ for (int slot : p.slots) b.writeVarInt(slot);
+ },
+ b ->
+ new Request(
+ b.readVarInt(),
+ b.readBoolean(),
+ b.readLong(),
+ b.readVarLong(),
+ b.readVarInt(),
+ b.readVarInt(),
+ b.readVarInt(),
+ b.readVarInt(),
+ ItemStack.OPTIONAL_STREAM_CODEC.decode(b),
+ readSlots(b)));
+
+ public Type type() {
+ return TYPE;
+ }
+ }
+
+ private static List readSlots(RegistryFriendlyByteBuf b) {
+ int count = boundedCount(b, ContainerDrawerMenu.MAX_PAGE + 36);
+ var slots = new ArrayList(count);
+ for (int i = 0; i < count; i++) slots.add(b.readVarInt());
+ return slots;
+ }
+
+ public record State(
+ int menu,
+ boolean equipment,
+ long session,
+ long revision,
+ int acknowledged,
+ boolean open,
+ int selected,
+ int offset,
+ int pageSize,
+ List icons,
+ List keys,
+ List sizes,
+ List contents,
+ List descriptions)
+ implements CustomPacketPayload {
+ public State {
+ descriptions = List.copyOf(descriptions);
+ icons = List.copyOf(icons);
+ keys = List.copyOf(keys);
+ sizes = List.copyOf(sizes);
+ contents = List.copyOf(contents);
+ if ((!descriptions.isEmpty() && descriptions.size() != contents.size())
+ || descriptions.size() > ContainerDrawerMenu.MAX_PAGE
+ || (!equipment && !descriptions.isEmpty())
+ || revision < 0
+ || acknowledged < 0
+ || pageSize < 1
+ || pageSize > ContainerDrawerMenu.MAX_PAGE
+ || icons.size() > ContainerDrawerMenu.MAX_CONTAINERS
+ || icons.size() != sizes.size()
+ || icons.size() != keys.size()
+ || keys.stream().anyMatch(k -> k.isEmpty() || k.length() > 256)
+ || selected < 0
+ || selected >= Math.max(1, icons.size())
+ || offset < 0
+ || offset >= ContainerDrawerMenu.MAX_SLOTS
+ || contents.size() > pageSize
+ || sizes.stream().anyMatch(n -> n < 1 || n > ContainerDrawerMenu.MAX_SLOTS)
+ || (open && icons.isEmpty()))
+ throw new IllegalArgumentException("Invalid container snapshot");
+ }
+
+ public static final Type TYPE =
+ new Type<>(ResourceLocation.parse("mmdlib:container_state"));
+ public static final StreamCodec CODEC =
+ StreamCodec.of(
+ (b, p) -> {
+ b.writeVarInt(p.menu);
+ b.writeBoolean(p.equipment);
+ b.writeLong(p.session);
+ b.writeVarLong(p.revision);
+ b.writeVarInt(p.acknowledged);
+ b.writeBoolean(p.open);
+ b.writeVarInt(p.selected);
+ b.writeVarInt(p.offset);
+ b.writeVarInt(p.pageSize);
+ b.writeVarInt(p.icons.size());
+ for (int i = 0; i < p.icons.size(); i++) {
+ ItemStack.OPTIONAL_STREAM_CODEC.encode(b, p.icons.get(i));
+ b.writeVarInt(p.sizes.get(i));
+ b.writeUtf(p.keys.get(i), 256);
+ }
+ b.writeVarInt(p.contents.size());
+ for (var stack : p.contents) ItemStack.OPTIONAL_STREAM_CODEC.encode(b, stack);
+ b.writeVarInt(p.descriptions.size());
+ for (var info : p.descriptions) {
+ b.writeUtf(info.identifier(), 128);
+ b.writeVarInt(info.index());
+ b.writeBoolean(info.cosmetic());
+ b.writeBoolean(info.available());
+ b.writeResourceLocation(info.icon());
+ }
+ },
+ b -> {
+ int menu = b.readVarInt();
+ boolean equipment = b.readBoolean();
+ long session = b.readLong(), revision = b.readVarLong();
+ int ack = b.readVarInt();
+ boolean open = b.readBoolean();
+ int selected = b.readVarInt(), offset = b.readVarInt(), page = b.readVarInt();
+ int count = boundedCount(b, ContainerDrawerMenu.MAX_CONTAINERS);
+ var icons = new ArrayList();
+ var sizes = new ArrayList();
+ var keys = new ArrayList();
+ for (int i = 0; i < count; i++) {
+ icons.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b));
+ sizes.add(b.readVarInt());
+ keys.add(b.readUtf(256));
+ }
+ int length = boundedCount(b, ContainerDrawerMenu.MAX_PAGE);
+ var contents = new ArrayList();
+ for (int i = 0; i < length; i++)
+ contents.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b));
+ int descriptionCount = boundedCount(b, ContainerDrawerMenu.MAX_PAGE);
+ var descriptions = new ArrayList();
+ for (int i = 0; i < descriptionCount; i++)
+ descriptions.add(
+ new EquippedContainers.SlotInfo(
+ b.readUtf(128),
+ b.readVarInt(),
+ b.readBoolean(),
+ b.readBoolean(),
+ b.readResourceLocation()));
+ return new State(
+ menu,
+ equipment,
+ session,
+ revision,
+ ack,
+ open,
+ selected,
+ offset,
+ page,
+ icons,
+ keys,
+ sizes,
+ contents,
+ descriptions);
+ });
+
+ public Type type() {
+ return TYPE;
+ }
+ }
+
+ private static int boundedCount(RegistryFriendlyByteBuf buffer, int max) {
+ int count = buffer.readVarInt();
+ if (count < 0 || count > max)
+ throw new IllegalArgumentException("Container snapshot too large");
+ return count;
+ }
+
+ public static void register(PayloadRegistrar registrar) {
+ registrar.playToServer(
+ Request.TYPE,
+ Request.CODEC,
+ (p, c) -> {
+ if (c.player().containerMenu instanceof MachineMenu menu)
+ (p.equipment() ? menu.curiosDrawer : menu.containerDrawer).action(c.player(), p);
+ });
+ registrar.playToClient(
+ State.TYPE,
+ State.CODEC,
+ (p, c) -> {
+ if (c.player().containerMenu instanceof MachineMenu menu)
+ (p.equipment() ? menu.curiosDrawer : menu.containerDrawer).receive(p);
+ });
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java
new file mode 100644
index 00000000..84b1d05b
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java
@@ -0,0 +1,50 @@
+package zone.moddev.mc.lib.gui.runtime;
+
+import java.util.List;
+
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.fml.ModList;
+import net.neoforged.neoforge.items.IItemHandler;
+
+/** Equipment discovery boundary. Storage access uses only the standard item capability. */
+public final class EquippedContainers {
+ private EquippedContainers() {}
+
+ /** The live equipped stack, its stable equipment address, and its current capability. */
+ public record Source(String key, ItemStack stack, IItemHandler items) {}
+
+ /** Public slot presentation, independent of the optional Curios classes. */
+ public record SlotInfo(
+ String identifier,
+ int index,
+ boolean cosmetic,
+ boolean available,
+ net.minecraft.resources.ResourceLocation icon) {
+ public SlotInfo {
+ if (identifier.isBlank()
+ || identifier.length() > 128
+ || index < 0
+ || index >= ContainerDrawerMenu.MAX_SLOTS)
+ throw new IllegalArgumentException("Invalid equipment slot");
+ }
+ }
+
+ public interface DescribedHandler extends IItemHandler {
+ SlotInfo describe(int slot);
+
+ default boolean quickInsert(int slot) {
+ return !describe(slot).cosmetic();
+ }
+ }
+
+ public static List equipment(Player player, ItemStack identity) {
+ if (!ModList.get().isLoaded("curios")) return List.of();
+ return zone.moddev.mc.lib.compat.curios.CuriosEquipment.find(player, identity);
+ }
+
+ public static List find(Player player) {
+ if (!ModList.get().isLoaded("curios")) return List.of();
+ return zone.moddev.mc.lib.compat.curios.CuriosContainers.find(player);
+ }
+}
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 fb549ace..4b1e0d0e 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
@@ -15,6 +15,7 @@
import net.neoforged.neoforge.network.PacketDistributor;
import zone.moddev.mc.lib.api.*;
+import zone.moddev.mc.lib.api.automation.AutomationInventory;
import zone.moddev.mc.lib.api.capabilities.*;
import zone.moddev.mc.lib.gui.*;
import zone.moddev.mc.lib.gui.layout.*;
@@ -40,6 +41,8 @@ public class MachineMenu extends AbstractContainerMenu {
@Nullable private final ReservedSlots reserved;
private final Player owner;
public final AutomationMenu automation;
+ public final ContainerDrawerMenu containerDrawer;
+ public final ContainerDrawerMenu curiosDrawer;
private final int baseMachineSlots;
private boolean initial = true;
private boolean interacting;
@@ -221,6 +224,17 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c
if (!bound.blockEntityClass().isInstance(entity))
throw new IllegalArgumentException("Wrong screen block entity");
elements = bound.screen().elements();
+ containerDrawer = new ContainerDrawerMenu(this, owner);
+ var equipmentIdentity = new ItemStack(net.minecraft.world.item.Items.ENDER_EYE);
+ curiosDrawer =
+ new ContainerDrawerMenu(
+ this,
+ owner,
+ () ->
+ bound.screen().playerInventory()
+ ? EquippedContainers.equipment(owner, equipmentIdentity)
+ : java.util.List.of(),
+ true);
for (int i = 0; i < elements.size(); i++) elementIndexes.put(elements.get(i), i);
if (elements.size() > 4096)
throw new IllegalArgumentException("A screen may have at most 4096 elements");
@@ -252,7 +266,7 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c
baseMachineSlots = count;
machineSlots = count + (automation == null ? 0 : automation.slotCount());
reserved = items instanceof ReservedSlots slots ? slots : null;
- var layout =
+ layout =
ScreenLayout.of(
bound.screen(),
owner.level().isClientSide ? ScreenRuntime.textMeasure : TextMeasure.NONE,
@@ -329,16 +343,16 @@ public int getMaxStackSize(ItemStack stack) {
.inventory()
.ifPresent(
r -> {
- for (int row = 0; row < 3; row++)
- for (int col = 0; col < 9; col++)
- addSlot(
- new Slot(
- inventory,
- 9 + row * 9 + col,
- r.x() + col * 18 + 1,
- r.y() + 11 + row * 18 + 1));
- for (int col = 0; col < 9; col++)
- addSlot(new Slot(inventory, col, r.x() + col * 18 + 1, r.y() + 11 + 58 + 1));
+ // Keep the ordinary 36 slots contiguous and in their existing menu order.
+ for (int n = 0; n < 36; n++) {
+ int index = (n + 9) % 36;
+ int[] xy = ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, index));
+ addSlot(new Slot(inventory, index, xy[0], xy[1]));
+ }
+ for (int index : new int[] {39, 38, 37, 36, 40}) {
+ int[] xy = ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, index));
+ addSlot(new PlayerEquipmentSlot(inventory, index, xy[0], xy[1]));
+ }
});
}
@@ -346,6 +360,51 @@ public Map views() {
return Map.copyOf(views);
}
+ private ScreenLayout layout;
+
+ /** Current geometry, shared by the screen and its persistent menu slots. */
+ public ScreenLayout layout() {
+ return layout;
+ }
+
+ private void relayout() {
+ layout =
+ ScreenLayout.of(
+ bound.screen(),
+ owner.level().isClientSide ? ScreenRuntime.textMeasure : TextMeasure.NONE,
+ automation != null,
+ views);
+ for (var element : elements) {
+ if (element instanceof Element.Slot slot) {
+ int[] xy = ScreenLayout.itemPosition(layout.bounds(element));
+ slots.get(slot.index()).x = xy[0];
+ slots.get(slot.index()).y = xy[1];
+ }
+ }
+ if (automation != null) {
+ for (int i = 0; i < automation.slotCount(); i++) {
+ var r =
+ AutomationLayout.item(i % AutomationInventory.SLOTS_PER_GROUP)
+ .offset(layout.panel().width() + AutomationLayout.RAIL, AutomationLayout.TOP);
+ var slot = slots.get(baseMachineSlots + i);
+ slot.x = r.x();
+ slot.y = r.y();
+ }
+ }
+ layout
+ .inventory()
+ .ifPresent(
+ r -> {
+ for (int i = machineSlots; i < slots.size(); i++) {
+ var slot = slots.get(i);
+ int[] xy =
+ ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, slot.getSlotIndex()));
+ slot.x = xy[0];
+ slot.y = xy[1];
+ }
+ });
+ }
+
public int index(Element element) {
var index = elementIndexes.get(element);
if (index == null) throw new IllegalArgumentException("Element is not on this screen");
@@ -404,6 +463,7 @@ public void navigate(String context, String view) {
&& !Objects.equals(views.get(context), view)) {
views.put(context, view);
visibility.clear();
+ relayout();
}
}
@@ -454,8 +514,9 @@ private ItemStack moveStack(Player player, int index) {
var stack = source.getItem();
var original = stack.copy();
if (index < machineSlots && planned(index)) return quickMovePlanned(player, source);
- if (index < machineSlots) {
- if (!moveItemStackTo(stack, machineSlots, slots.size(), true)) return ItemStack.EMPTY;
+ if (index < machineSlots || index >= machineSlots + 36) {
+ // Outputs go into storage/hotbar; shift-clicking equipment unequips it into that range.
+ if (!moveItemStackTo(stack, machineSlots, playerStorageEnd(), true)) return ItemStack.EMPTY;
} else {
if (host == null) return ItemStack.EMPTY;
var destinations = new ArrayList();
@@ -477,7 +538,7 @@ private ItemStack moveStack(Player player, int index) {
moveItemStackTo(stack, slot.index, slot.index + 1, false);
if (stack.getCount() == original.getCount()) return ItemStack.EMPTY;
}
- if (stack.isEmpty()) source.setByPlayer(ItemStack.EMPTY);
+ if (stack.isEmpty()) source.setByPlayer(ItemStack.EMPTY, original);
else source.setChanged();
source.onTake(player, stack);
return original;
@@ -490,7 +551,7 @@ private ItemStack quickMovePlanned(Player player, Slot source) {
if (free <= 0) return ItemStack.EMPTY;
var moving = source.getItem().copyWithCount(free);
var original = moving.copy();
- if (!moveItemStackTo(moving, machineSlots, slots.size(), true)) return ItemStack.EMPTY;
+ if (!moveItemStackTo(moving, machineSlots, playerStorageEnd(), true)) return ItemStack.EMPTY;
int moved = free - moving.getCount();
var taken = source.remove(moved);
source.onTake(player, taken);
@@ -505,6 +566,10 @@ private boolean planned(int index) {
&& reserved.hasPlan(index);
}
+ private int playerStorageEnd() {
+ return Math.min(machineSlots + 36, slots.size());
+ }
+
@Override
public boolean canDragTo(Slot slot) {
return slot.isActive() && super.canDragTo(slot);
@@ -519,6 +584,8 @@ public boolean canTakeItemForPickAll(ItemStack stack, Slot slot) {
public void broadcastChanges() {
if (automation != null) automation.beforeBroadcast();
super.broadcastChanges();
+ containerDrawer.broadcast();
+ curiosDrawer.broadcast();
if (automation != null) automation.broadcast();
if (!(owner instanceof net.minecraft.server.level.ServerPlayer player)
|| !stillValid(owner)
diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java
new file mode 100644
index 00000000..b57b2e90
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java
@@ -0,0 +1,71 @@
+package zone.moddev.mc.lib.gui.runtime;
+
+import com.mojang.datafixers.util.Pair;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.InventoryMenu;
+import net.minecraft.world.inventory.Slot;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.enchantment.EnchantmentEffectComponents;
+import net.minecraft.world.item.enchantment.EnchantmentHelper;
+
+/** Player-owned armor and offhand slots, with the same rules and callbacks as InventoryMenu. */
+final class PlayerEquipmentSlot extends Slot {
+ private final Player owner;
+ private final EquipmentSlot equipment;
+ private final ResourceLocation icon;
+
+ PlayerEquipmentSlot(Inventory inventory, int index, int x, int y) {
+ super(inventory, index, x, y);
+ owner = inventory.player;
+ equipment =
+ switch (index) {
+ case 39 -> EquipmentSlot.HEAD;
+ case 38 -> EquipmentSlot.CHEST;
+ case 37 -> EquipmentSlot.LEGS;
+ case 36 -> EquipmentSlot.FEET;
+ case 40 -> EquipmentSlot.OFFHAND;
+ default -> throw new IllegalArgumentException("Equipment slot: " + index);
+ };
+ icon =
+ switch (equipment) {
+ case HEAD -> InventoryMenu.EMPTY_ARMOR_SLOT_HELMET;
+ case CHEST -> InventoryMenu.EMPTY_ARMOR_SLOT_CHESTPLATE;
+ case LEGS -> InventoryMenu.EMPTY_ARMOR_SLOT_LEGGINGS;
+ case FEET -> InventoryMenu.EMPTY_ARMOR_SLOT_BOOTS;
+ default -> InventoryMenu.EMPTY_ARMOR_SLOT_SHIELD;
+ };
+ }
+
+ @Override
+ public void setByPlayer(ItemStack stack, ItemStack previous) {
+ owner.onEquipItem(equipment, previous, stack);
+ super.setByPlayer(stack, previous);
+ }
+
+ @Override
+ public int getMaxStackSize() {
+ return equipment == EquipmentSlot.OFFHAND ? super.getMaxStackSize() : 1;
+ }
+
+ @Override
+ public boolean mayPlace(ItemStack stack) {
+ return equipment == EquipmentSlot.OFFHAND || stack.canEquip(equipment, owner);
+ }
+
+ @Override
+ public boolean mayPickup(Player player) {
+ return (equipment == EquipmentSlot.OFFHAND
+ || player.isCreative()
+ || !EnchantmentHelper.has(getItem(), EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE))
+ && super.mayPickup(player);
+ }
+
+ @Override
+ public Pair getNoItemIcon() {
+ return Pair.of(InventoryMenu.BLOCK_ATLAS, icon);
+ }
+}
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 80137367..42d49082 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
@@ -173,8 +173,9 @@ public Type type() {
}
public static void register(RegisterPayloadHandlersEvent event) {
- var r = event.registrar("3");
+ var r = event.registrar("6");
AutomationNetwork.register(r);
+ ContainerDrawerNetwork.register(r);
r.playToServer(
Widget.TYPE,
Widget.CODEC,
diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg
new file mode 100644
index 00000000..adde41ce
--- /dev/null
+++ b/src/main/resources/META-INF/accesstransformer.cfg
@@ -0,0 +1,3 @@
+# Keep menu slot identities and sync state while context views resize the screen.
+public-f net.minecraft.world.inventory.Slot x
+public-f net.minecraft.world.inventory.Slot y
diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml
index 72819662..0968d0a4 100644
--- a/src/main/resources/META-INF/neoforge.mods.toml
+++ b/src/main/resources/META-INF/neoforge.mods.toml
@@ -12,6 +12,8 @@ loaderVersion="${loader_version_range}" #mandatory This is typically bumped ever
license="${mod_license}"
# A URL to refer people to when problems occur with this mod
issueTrackerURL="${issue_url}" #optional
+[[accessTransformers]]
+file="META-INF/accesstransformer.cfg"
# A list of mods - how many allowed here is determined by the individual mod loader
[[mods]] #mandatory
# The modid of the mod
@@ -63,6 +65,14 @@ ${mod_description}
versionRange="${minecraft_version_range}"
ordering="NONE"
side="BOTH"
+# Optional: equipped item storage is available when Curios is installed.
+[[dependencies.${mod_id}]]
+ modId="curios"
+ type="optional"
+ versionRange="${curios_version_range}"
+ ordering="AFTER"
+ side="BOTH"
+
# Optional: JEI shows crackhammer crushing recipes when installed; never required.
[[dependencies.${mod_id}]]
modId="jei"
diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json
index 9f3d6c82..95abf62f 100644
--- a/src/main/resources/assets/mmdlib/lang/en_us.json
+++ b/src/main/resources/assets/mmdlib/lang/en_us.json
@@ -285,5 +285,20 @@
"screen.mmdlib.automation.sample": "Click with a sample; right-click to clear",
"screen.mmdlib.automation.empty": "No neighbouring block",
"screen.mmdlib.automation.unloaded": "Neighbour not loaded",
- "screen.mmdlib.automation.addon": "Compatible addon"
+ "screen.mmdlib.automation.addon": "Compatible addon",
+ "screen.mmdlib.container.toggle": "Open or close equipped storage",
+ "screen.mmdlib.container.room": "Reduce GUI scale or enlarge the window to show storage.",
+ "screen.mmdlib.container.select": "Click to select the next equipped container.",
+ "screen.mmdlib.container.store": "Shift-click inventory items to store them here.",
+ "screen.mmdlib.container.gestures": "Drag to distribute; double-click to collect; hotbar keys swap slots.",
+ "screen.mmdlib.curios.title": "Curios",
+ "screen.mmdlib.curios.toggle": "Open or close equipped accessories",
+ "screen.mmdlib.curios.store": "Shift-click inventory items to equip them; shift-click accessories to unequip.",
+ "screen.mmdlib.curios.equipment": "Slot",
+ "screen.mmdlib.curios.functional.short": "E",
+ "screen.mmdlib.curios.cosmetic.short": "C",
+ "screen.mmdlib.curios.functional": "Equipped slot",
+ "screen.mmdlib.curios.cosmetic": "Cosmetic slot",
+ "screen.mmdlib.curios.columns": "E: Equipped • C: Cosmetic",
+ "screen.mmdlib.curios.unavailable": "This slot has no cosmetic counterpart."
}
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java
new file mode 100644
index 00000000..ee4c5dfd
--- /dev/null
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java
@@ -0,0 +1,243 @@
+package zone.moddev.mc.lib.smoke;
+
+import java.util.ArrayList;
+import java.util.List;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.neoforged.neoforge.items.IItemHandler;
+import net.neoforged.neoforge.items.ItemStackHandler;
+import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu;
+import zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork;
+import zone.moddev.mc.lib.gui.runtime.EquippedContainers;
+import zone.moddev.mc.lib.gui.runtime.MachineMenu;
+
+/** Exercises authoritative transfers through a capability that deliberately has no setter API. */
+final class ContainerDrawerSmoke {
+ static void check(ServerLevel level, BlockPos pos, Player player) {
+ require(!net.neoforged.fml.ModList.get().isLoaded("curios"), "Standalone run has no Curios");
+ require(EquippedContainers.find(player).isEmpty(), "Absent optional API is safe");
+ level.setBlock(pos, BuiltInRegistries.BLOCK.get(CrusherSmoke.ID).defaultBlockState(), 3);
+ player.setPos(pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5);
+ var menu = new MachineMenu(93, player.getInventory(), level.getBlockEntity(pos));
+ player.containerMenu = menu;
+ menu.curiosDrawer.refresh();
+ require(
+ menu.curiosDrawer.state().equipment() && menu.curiosDrawer.state().icons().isEmpty(),
+ "Equipment controller loads safely with Curios absent");
+ player.getInventory().clearContent();
+ var backing = new ItemStackHandler(70);
+ var handler =
+ new IItemHandler() {
+ public int getSlots() {
+ return backing.getSlots();
+ }
+
+ public ItemStack getStackInSlot(int slot) {
+ return backing.getStackInSlot(slot);
+ }
+
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ return isItemValid(slot, stack) ? backing.insertItem(slot, stack, simulate) : stack;
+ }
+
+ public ItemStack extractItem(int slot, int amount, boolean simulate) {
+ return slot == 2 ? ItemStack.EMPTY : backing.extractItem(slot, amount, simulate);
+ }
+
+ public int getSlotLimit(int slot) {
+ return backing.getSlotLimit(slot);
+ }
+
+ public boolean isItemValid(int slot, ItemStack stack) {
+ return stack.is(Items.IRON_INGOT);
+ }
+ };
+ var icon = new ItemStack(Items.LEATHER_CHESTPLATE);
+ icon.set(DataComponents.CUSTOM_NAME, Component.literal("Actual equipped item"));
+ var sources = new ArrayList<>(List.of(new EquippedContainers.Source("back:0", icon, handler)));
+ var drawer = new ContainerDrawerMenu(menu, player, () -> sources);
+ drawer.refresh();
+ require(
+ ItemStack.matches(icon, drawer.state().icons().getFirst()), "Icon preserves components");
+ var unchanged = drawer.state();
+ drawer.refresh();
+ require(drawer.state() == unchanged, "Unchanged closed drawer reuses its snapshot");
+ backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 15));
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 0);
+ require(menu.getCarried().isEmpty(), "Closed drawer cannot transfer");
+ send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0);
+ unchanged = drawer.state();
+ drawer.refresh();
+ require(drawer.state() == unchanged, "Unchanged open drawer reuses its snapshot");
+ var oldContents = unchanged.contents();
+ backing.getStackInSlot(0).grow(1);
+ drawer.refresh();
+ require(
+ drawer.state() != unchanged && oldContents.getFirst().getCount() == 15,
+ "In-place count changes refresh without mutating previous snapshots");
+ backing.getStackInSlot(0).shrink(1);
+ drawer.refresh();
+ unchanged = drawer.state();
+ icon.set(DataComponents.CUSTOM_NAME, Component.literal("Renamed equipped item"));
+ drawer.refresh();
+ require(
+ drawer.state() != unchanged && drawer.state().contents() == unchanged.contents(),
+ "Icon changes reuse unchanged content copies");
+
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 1);
+ require(
+ menu.getCarried().getCount() == 8 && backing.getStackInSlot(0).getCount() == 7,
+ "Right pickup splits");
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 1);
+ require(
+ menu.getCarried().getCount() == 7 && backing.getStackInSlot(0).getCount() == 8,
+ "Right insert places one");
+ backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 62));
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 0);
+ require(
+ menu.getCarried().getCount() == 5 && backing.getStackInSlot(0).getCount() == 64,
+ "Partial insertion keeps remainder");
+ menu.setCarried(new ItemStack(Items.DIAMOND, 3));
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 2);
+ require(
+ menu.getCarried().getCount() == 3 && backing.getStackInSlot(1).isEmpty(),
+ "Insertion filter honored");
+ menu.setCarried(ItemStack.EMPTY);
+ backing.setStackInSlot(2, new ItemStack(Items.IRON_INGOT, 9));
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 4);
+ require(menu.getCarried().isEmpty(), "Extraction filter honored");
+ var stale = request(drawer, menu, ContainerDrawerMenu.PICKUP, 0);
+ backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 60));
+ drawer.action(player, stale);
+ require(
+ menu.getCarried().isEmpty() && backing.getStackInSlot(0).getCount() == 60,
+ "Stale revision refused");
+ var wrongCursor = request(drawer, menu, ContainerDrawerMenu.PICKUP, 0);
+ menu.setCarried(new ItemStack(Items.DIAMOND));
+ drawer.action(player, wrongCursor);
+ require(backing.getStackInSlot(0).getCount() == 60, "Cursor mismatch refused");
+ menu.setCarried(ItemStack.EMPTY);
+ player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 10));
+ send(drawer, menu, player, ContainerDrawerMenu.QUICK_STORE, 0);
+ require(
+ player.getInventory().getItem(0).isEmpty()
+ && backing.getStackInSlot(0).getCount() == 64
+ && backing.getStackInSlot(2).getCount() == 15,
+ "Shift store merges before empty slots");
+ for (int i = 0; i < 36; i++) player.getInventory().setItem(i, new ItemStack(Items.STONE, 64));
+ send(drawer, menu, player, ContainerDrawerMenu.QUICK_TAKE, 0);
+ require(
+ backing.getStackInSlot(0).getCount() == 64 && player.getInventory().getItem(40).isEmpty(),
+ "Full inventory does not spill into equipment");
+ player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 61));
+ var transfer = request(drawer, menu, ContainerDrawerMenu.QUICK_TAKE, 0);
+ drawer.action(player, transfer);
+ drawer.action(player, transfer);
+ require(
+ backing.getStackInSlot(0).getCount() == 61
+ && player.getInventory().getItem(0).getCount() == 64,
+ "Partial extraction and duplicate request conserve items");
+ backing.setStackInSlot(60, new ItemStack(Items.IRON_INGOT, 2));
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 120);
+ require(menu.getCarried().isEmpty(), "Off-page slot refused");
+ send(drawer, menu, player, ContainerDrawerMenu.PAGE, 54);
+ send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 120);
+ require(menu.getCarried().getCount() == 2, "Paged slot is accessible");
+ var old = request(drawer, menu, ContainerDrawerMenu.PICKUP, 120);
+ sources.set(0, new EquippedContainers.Source("back:0", icon.copy(), handler));
+ drawer.action(player, old);
+ require(
+ !drawer.state().open() && menu.getCarried().getCount() == 2,
+ "Replacing identical item closes drawer");
+ sources.add(
+ new EquippedContainers.Source(
+ "back:1", new ItemStack(Items.CHEST), new ItemStackHandler(1)));
+ drawer.refresh();
+ send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0);
+ send(drawer, menu, player, ContainerDrawerMenu.SELECT, 1);
+ require(
+ drawer.state().selected() == 1 && drawer.state().contents().size() == 1,
+ "Select another equipped container");
+ var buf =
+ new net.minecraft.network.RegistryFriendlyByteBuf(
+ io.netty.buffer.Unpooled.buffer(), level.registryAccess());
+ try {
+ ContainerDrawerNetwork.State.CODEC.encode(buf, drawer.state());
+ var decoded = ContainerDrawerNetwork.State.CODEC.decode(buf);
+ require(
+ decoded.selected() == 1
+ && decoded.keys().get(1).equals("back:1")
+ && ItemStack.matches(decoded.icons().getFirst(), icon),
+ "Snapshot codec retains item components");
+ var receiver = new ContainerDrawerMenu(menu, player, () -> sources);
+ receiver.receive(decoded);
+ require(
+ receiver.request(ContainerDrawerMenu.SYNC, 0, 27).orElseThrow().request()
+ > decoded.acknowledged(),
+ "Snapshot acknowledgments advance the next request sequence");
+ require(!receiver.pendingInventory(), "Sync does not block vanilla inventory");
+ var acknowledgement =
+ new ContainerDrawerNetwork.State(
+ decoded.menu(),
+ decoded.equipment(),
+ decoded.session(),
+ decoded.revision(),
+ decoded.acknowledged() + 1,
+ decoded.open(),
+ decoded.selected(),
+ decoded.offset(),
+ decoded.pageSize(),
+ decoded.icons(),
+ decoded.keys(),
+ decoded.sizes(),
+ decoded.contents(),
+ decoded.descriptions());
+ receiver.receive(acknowledgement);
+ receiver.request(ContainerDrawerMenu.PICKUP, 0, 27).orElseThrow();
+ require(receiver.pendingInventory(), "Item transfers wait for authoritative cursor updates");
+ menu.curiosDrawer.receive(decoded);
+ require(
+ menu.curiosDrawer.state().equipment() && menu.curiosDrawer.state().icons().isEmpty(),
+ "Backpack snapshots cannot overwrite the equipment channel");
+ } finally {
+ buf.release();
+ }
+ sources.clear();
+ drawer.refresh();
+ require(
+ !drawer.state().open() && drawer.state().icons().isEmpty(), "Unequipping removes drawer");
+ ContainerInteractionSmoke.check(menu, player);
+ player.containerMenu = player.inventoryMenu;
+ menu.setCarried(ItemStack.EMPTY);
+ player.getInventory().clearContent();
+ }
+
+ private static ContainerDrawerNetwork.Request request(
+ ContainerDrawerMenu drawer, MachineMenu menu, int kind, int arg) {
+ return new ContainerDrawerNetwork.Request(
+ menu.containerId,
+ menu.widgetSession(),
+ drawer.state().revision(),
+ drawer.state().acknowledged() + 1,
+ kind,
+ arg,
+ 27,
+ menu.getCarried().copy());
+ }
+
+ private static void send(
+ ContainerDrawerMenu drawer, MachineMenu menu, Player player, int kind, int arg) {
+ drawer.refresh();
+ drawer.action(player, request(drawer, menu, kind, arg));
+ }
+
+ private static void require(boolean ok, String message) {
+ if (!ok) throw new AssertionError(message);
+ }
+}
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java
new file mode 100644
index 00000000..4d49f8f3
--- /dev/null
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java
@@ -0,0 +1,200 @@
+package zone.moddev.mc.lib.smoke;
+
+import java.util.List;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.neoforged.neoforge.items.ItemStackHandler;
+import zone.moddev.mc.lib.gui.runtime.*;
+
+/** Conservation, filter, replay and off-page checks for batched backpack gestures. */
+final class ContainerInteractionSmoke {
+ static void check(MachineMenu menu, Player player) {
+ player.getInventory().clearContent();
+ var items =
+ new ItemStackHandler(70) {
+ public int getSlotLimit(int slot) {
+ return slot == 1 ? 4 : 64;
+ }
+
+ public boolean isItemValid(int slot, ItemStack stack) {
+ return slot != 2 && !stack.is(Items.DIRT);
+ }
+
+ public ItemStack extractItem(int slot, int count, boolean simulate) {
+ return slot == 3 ? ItemStack.EMPTY : super.extractItem(slot, count, simulate);
+ }
+ };
+ var icon = new ItemStack(Items.CHEST);
+ var drawer =
+ new ContainerDrawerMenu(
+ menu, player, () -> List.of(new EquippedContainers.Source("back:1", icon, items)));
+ send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of());
+ menu.setCarried(new ItemStack(Items.IRON_INGOT, 20));
+ send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 1, 2, 4096));
+ require(
+ items.getStackInSlot(0).getCount() == 6
+ && items.getStackInSlot(1).getCount() == 4
+ && items.getStackInSlot(2).isEmpty()
+ && player.getInventory().getItem(0).getCount() == 6
+ && menu.getCarried().getCount() == 4,
+ "Even drag respects filters, limits and remainder");
+ send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 1, List.of(0, 4));
+ require(
+ items.getStackInSlot(0).getCount() == 7
+ && items.getStackInSlot(4).getCount() == 1
+ && menu.getCarried().getCount() == 2,
+ "Right drag places one per target");
+ send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 0));
+ send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 60));
+ require(
+ menu.getCarried().getCount() == 2, "Duplicate and off-page targets reject whole gesture");
+ drawer.refresh();
+ var request = request(drawer, menu, ContainerDrawerMenu.DISTRIBUTE, 1, List.of(0, 4));
+ drawer.action(player, request);
+ drawer.action(player, request);
+ require(
+ menu.getCarried().isEmpty()
+ && items.getStackInSlot(0).getCount() == 8
+ && items.getStackInSlot(4).getCount() == 2,
+ "Replayed drag cannot duplicate items");
+ menu.setCarried(new ItemStack(Items.IRON_INGOT, 1));
+ items.setStackInSlot(3, new ItemStack(Items.IRON_INGOT, 9));
+ items.setStackInSlot(60, new ItemStack(Items.IRON_INGOT, 20));
+ var named = new ItemStack(Items.IRON_INGOT, 5);
+ named.set(
+ net.minecraft.core.component.DataComponents.CUSTOM_NAME,
+ net.minecraft.network.chat.Component.literal("Keep"));
+ items.setStackInSlot(5, named);
+ send(drawer, menu, player, ContainerDrawerMenu.COLLECT, 0, List.of());
+ require(
+ menu.getCarried().getCount() == 41
+ && items.getStackInSlot(60).isEmpty()
+ && player.getInventory().getItem(0).isEmpty()
+ && items.getStackInSlot(3).getCount() == 9
+ && ItemStack.matches(items.getStackInSlot(5), named),
+ "Collect includes other pages and player slots but honors components/extraction");
+ items.setStackInSlot(61, new ItemStack(Items.IRON_INGOT, 50));
+ send(drawer, menu, player, ContainerDrawerMenu.COLLECT, 0, List.of());
+ require(
+ menu.getCarried().getCount() == 64 && items.getStackInSlot(61).getCount() == 27,
+ "Collect caps cursor stack");
+ menu.setCarried(ItemStack.EMPTY);
+ player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 2));
+ items.setStackInSlot(0, new ItemStack(Items.COPPER_INGOT, 5));
+ send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ require(
+ items.getStackInSlot(0).is(Items.DIAMOND)
+ && items.getStackInSlot(0).getCount() == 2
+ && player.getInventory().getItem(0).is(Items.COPPER_INGOT)
+ && player.getInventory().getItem(0).getCount() == 5,
+ "Hotbar swap exchanges whole stacks");
+ player.getInventory().setItem(0, new ItemStack(Items.DIRT, 3));
+ send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ require(
+ items.getStackInSlot(0).is(Items.DIAMOND)
+ && player.getInventory().getItem(0).getCount() == 3,
+ "Swap rejects invalid insertion");
+ player.getInventory().setItem(0, ItemStack.EMPTY);
+ send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(3));
+ require(player.getInventory().getItem(0).isEmpty(), "Swap honors locked extraction");
+ send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ require(
+ items.getStackInSlot(0).isEmpty() && player.getInventory().getItem(0).is(Items.DIAMOND),
+ "Swap into empty hotbar");
+ drawer.refresh();
+ var stale = request(drawer, menu, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ items.setStackInSlot(6, new ItemStack(Items.STONE));
+ drawer.action(player, stale);
+ require(items.getStackInSlot(0).isEmpty(), "Stale swap rejected");
+ var rejecting =
+ new ItemStackHandler(1) {
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ return stack.is(Items.DIAMOND) ? stack : super.insertItem(slot, stack, simulate);
+ }
+ };
+ rejecting.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 5));
+ var rollback =
+ new ContainerDrawerMenu(
+ menu, player, () -> List.of(new EquippedContainers.Source("back:2", icon, rejecting)));
+ player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 2));
+ send(rollback, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of());
+ send(rollback, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ require(
+ rejecting.getStackInSlot(0).getCount() == 5
+ && rejecting.getStackInSlot(0).is(Items.IRON_INGOT)
+ && player.getInventory().getItem(0).getCount() == 2
+ && player.getInventory().getItem(0).is(Items.DIAMOND),
+ "Rejected swap restores the extracted stack without consuming incoming items");
+ var partial =
+ new ItemStackHandler(1) {
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ if (!simulate && stack.is(Items.DIAMOND)) {
+ var left =
+ super.insertItem(slot, stack.copyWithCount(Math.min(2, stack.getCount())), false);
+ return stack.copyWithCount(
+ stack.getCount() - Math.min(2, stack.getCount()) + left.getCount());
+ }
+ return super.insertItem(slot, stack, simulate);
+ }
+ };
+ partial.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 5));
+ var partialDrawer =
+ new ContainerDrawerMenu(
+ menu, player, () -> List.of(new EquippedContainers.Source("back:3", icon, partial)));
+ player.getInventory().clearContent();
+ player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 4));
+ send(partialDrawer, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of());
+ send(partialDrawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0));
+ require(
+ partial.getStackInSlot(0).getCount() == 2
+ && player.getInventory().getItem(0).getCount() == 2
+ && player.getInventory().getItem(1).is(Items.IRON_INGOT)
+ && player.getInventory().getItem(1).getCount() == 5,
+ "Partial swap preserves both remainders");
+ var buffer =
+ new net.minecraft.network.RegistryFriendlyByteBuf(
+ io.netty.buffer.Unpooled.buffer(), player.registryAccess());
+ try {
+ ContainerDrawerNetwork.Request.CODEC.encode(
+ buffer, request(drawer, menu, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 4096)));
+ require(
+ ContainerDrawerNetwork.Request.CODEC.decode(buffer).slots().equals(List.of(0, 4096)),
+ "Gesture codec retains bounded targets");
+ } finally {
+ buffer.release();
+ }
+ menu.setCarried(ItemStack.EMPTY);
+ player.getInventory().clearContent();
+ }
+
+ private static ContainerDrawerNetwork.Request request(
+ ContainerDrawerMenu drawer, MachineMenu menu, int kind, int arg, List slots) {
+ return new ContainerDrawerNetwork.Request(
+ menu.containerId,
+ false,
+ menu.widgetSession(),
+ drawer.state().revision(),
+ drawer.state().acknowledged() + 1,
+ kind,
+ arg,
+ 27,
+ menu.getCarried().copy(),
+ slots);
+ }
+
+ private static void send(
+ ContainerDrawerMenu drawer,
+ MachineMenu menu,
+ Player player,
+ int kind,
+ int arg,
+ List slots) {
+ drawer.refresh();
+ drawer.action(player, request(drawer, menu, kind, arg, slots));
+ }
+
+ private static void require(boolean ok, String message) {
+ if (!ok) throw new AssertionError(message);
+ }
+}
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java
index 3233801f..beeb2709 100644
--- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java
@@ -194,6 +194,25 @@ public static void frame(RenderFrameEvent.Post event) {
return;
}
GuiGaugeSmoke.check(screen);
+ var inventoryBounds = screen.layout.inventory().orElseThrow();
+ int equipment = 0;
+ for (var slot : screen.getMenu().slots) {
+ if (slot.container != mc.player.getInventory() || slot.getContainerSlot() < 36) continue;
+ var expected =
+ zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(
+ inventoryBounds, slot.getContainerSlot());
+ if (slot.x != expected.x() + 1
+ || slot.y != expected.y() + 1
+ || slot.getNoItemIcon() == null) {
+ fail(new AssertionError("Client equipment position or empty icon missing"));
+ return;
+ }
+ equipment++;
+ }
+ if (equipment != 5) {
+ fail(new AssertionError("Client equipment slots missing"));
+ return;
+ }
Screenshot.grab(
mc.gameDirectory,
"gui-smoke.png",
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java
index 63ad0005..a49aa164 100644
--- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java
@@ -156,6 +156,26 @@ private static void checkTransfer(
false,
false);
if (error != null) throw new AssertionError("JEI transfer validation failed: " + error);
+ // The same ingredient in the offhand must not be consumed as a recipe input.
+ var inventory = Minecraft.getInstance().player.getInventory();
+ var ingredient = inventory.getItem(9);
+ var previousOffhand = inventory.getItem(40);
+ try {
+ inventory.setItem(9, ItemStack.EMPTY);
+ inventory.setItem(40, ingredient);
+ var equippedOnly =
+ handler.transferRecipe(
+ screen.getMenu(),
+ recipe,
+ layout.getRecipeSlotsView(),
+ Minecraft.getInstance().player,
+ false,
+ false);
+ if (equippedOnly == null) throw new AssertionError("JEI used the offhand as recipe storage");
+ } finally {
+ inventory.setItem(9, ingredient);
+ inventory.setItem(40, previousOffhand);
+ }
error =
handler.transferRecipe(
screen.getMenu(),
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java
index c42194b5..c5459445 100644
--- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java
@@ -33,7 +33,7 @@ public static void install() {
MaterialEventTypes.GUI_CLICK,
c -> ((Counter) c.menu().blockEntity).clicks++);
String xml =
- """
+"""
@@ -126,7 +126,21 @@ public static void check(ServerPlayer player, Counter entity) {
"Hidden side action");
require(menu.ghost(player, ghost, new ItemStack(Items.DIAMOND, 64)), "Ghost filter rejected");
require(entity.ghost.getCount() == 1 && entity.ghost.is(Items.DIAMOND), "Ghost filter count");
+ var originalSlots = java.util.List.copyOf(menu.slots);
+ var originalPanel = menu.layout().panel();
menu.clickMenuButton(player, new ScreenNetwork.Action(73, nav, 1, 0, 0, 0).pack());
+ require(!originalPanel.equals(menu.layout().panel()), "Context did not resize panel");
+ for (int i = 0; i < menu.slots.size(); i++) {
+ var slot = menu.slots.get(i);
+ require(slot == originalSlots.get(i), "Context replaced slot identity");
+ if (slot.container == player.getInventory()) {
+ var r =
+ zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(
+ menu.layout().inventory().orElseThrow(), slot.getSlotIndex());
+ require(
+ slot.x == r.x() + 1 && slot.y == r.y() + 1, "Player slot layout after navigation");
+ }
+ }
require(menu.slots.get(1).isActive() && !menu.slots.get(0).isActive(), "Context visibility");
require(!menu.ghost(player, ghost, new ItemStack(Items.STONE)), "Hidden ghost changed");
menu.clickMenuButton(player, new ScreenNetwork.Action(73, config, 3, 0, 0, 0).pack());
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java
index 8ded5fa6..9eaa5c32 100644
--- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java
@@ -20,6 +20,7 @@ public static void processorIdleSearchBackoff(GameTestHelper helper) {
ProcessorSmoke.idleSearchBackoff(helper.getLevel());
helper.succeed();
}
+
private static final String EMPTY = "empty";
/** Inside the structure, away from its edges. */
@@ -27,6 +28,23 @@ public static void processorIdleSearchBackoff(GameTestHelper helper) {
private MMDLibGameTests() {}
+ @GameTest(template = EMPTY)
+ public static void equippedContainer(GameTestHelper helper) {
+ ContainerDrawerSmoke.check(helper.getLevel(), helper.absolutePos(CENTER),
+ helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL));
+ helper.succeed();
+ }
+
+ @GameTest(template = EMPTY)
+ public static void playerEquipment(GameTestHelper helper) {
+ PlayerEquipmentSmoke.check(
+ helper.getLevel(),
+ helper.absolutePos(CENTER),
+ helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL),
+ helper.makeMockPlayer(net.minecraft.world.level.GameType.CREATIVE));
+ helper.succeed();
+ }
+
@GameTest(template = EMPTY)
public static void contentRegistered(GameTestHelper helper) {
ContentSmoke.registered();
diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java
new file mode 100644
index 00000000..7219faed
--- /dev/null
+++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java
@@ -0,0 +1,112 @@
+package zone.moddev.mc.lib.smoke;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.ClickType;
+import net.minecraft.world.inventory.Slot;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.enchantment.Enchantments;
+
+import zone.moddev.mc.lib.gui.layout.ScreenLayout;
+import zone.moddev.mc.lib.gui.layout.TextMeasure;
+import zone.moddev.mc.lib.gui.runtime.MachineMenu;
+
+/** Real player inventory ownership, armor restrictions and equipment transfer regression checks. */
+final class PlayerEquipmentSmoke {
+ static void check(ServerLevel level, BlockPos pos, Player player, Player creative) {
+ level.setBlock(pos, BuiltInRegistries.BLOCK.get(CrusherSmoke.ID).defaultBlockState(), 3);
+ var entity = (CrusherSmoke.Crusher) level.getBlockEntity(pos);
+ var menu = new MachineMenu(92, player.getInventory(), entity);
+ player.getInventory().clearContent();
+ var inventory = player.getInventory();
+ var bounds = ScreenLayout.of(menu.bound.screen(), TextMeasure.NONE).inventory().orElseThrow();
+ require(
+ menu.slots.stream().filter(s -> s.container == inventory).count() == 41, "41 player slots");
+ for (int index = 0; index <= 40; index++) {
+ var slot = slot(menu, player, index);
+ var expected = ScreenLayout.itemPosition(ScreenLayout.playerSlot(bounds, index));
+ require(slot.x == expected[0] && slot.y == expected[1], "Slot position " + index);
+ }
+ var helmet = slot(menu, player, 39);
+ var offhand = slot(menu, player, 40);
+ var armor =
+ new net.minecraft.world.item.Item[] {
+ Items.DIAMOND_BOOTS,
+ Items.DIAMOND_LEGGINGS,
+ Items.DIAMOND_CHESTPLATE,
+ Items.DIAMOND_HELMET
+ };
+ for (int index = 36; index <= 39; index++) {
+ var slot = slot(menu, player, index);
+ require(slot.getMaxStackSize(new ItemStack(armor[index - 36])) == 1, "Armor limit");
+ for (int other = 36; other <= 39; other++)
+ require(slot.mayPlace(new ItemStack(armor[other - 36])) == (index == other), "Armor type");
+ require(!slot.mayPlace(new ItemStack(Items.STONE)), "Reject non-armor");
+ require(slot.getNoItemIcon() != null, "Empty armor icon");
+ }
+ require(
+ offhand.mayPlace(new ItemStack(Items.STONE))
+ && offhand.getMaxStackSize(new ItemStack(Items.STONE)) == 64,
+ "Offhand accepts ordinary stacks");
+ require(offhand.getNoItemIcon() != null, "Empty offhand icon");
+ menu.setCarried(new ItemStack(Items.DIAMOND_HELMET));
+ menu.clicked(helmet.index, 0, ClickType.PICKUP, player);
+ require(
+ inventory.getItem(39).is(Items.DIAMOND_HELMET) && menu.getCarried().isEmpty(),
+ "Equip helmet");
+ require(
+ !menu.quickMoveStack(player, helmet.index).isEmpty() && inventory.getItem(39).isEmpty(),
+ "Shift-click unequips armor");
+ require(entity.items.getStackInSlot(0).isEmpty(), "Unequipping does not feed the machine");
+ var cursed = new ItemStack(Items.DIAMOND_HELMET);
+ cursed.enchant(
+ level
+ .registryAccess()
+ .lookupOrThrow(Registries.ENCHANTMENT)
+ .getOrThrow(Enchantments.BINDING_CURSE),
+ 1);
+ helmet.setByPlayer(cursed);
+ require(!helmet.mayPickup(player), "Binding curse prevents pickup");
+ menu.clicked(helmet.index, 0, ClickType.PICKUP, player);
+ require(menu.getCarried().isEmpty() && helmet.hasItem(), "Cursed click refused");
+ require(menu.quickMoveStack(player, helmet.index).isEmpty(), "Cursed shift-click refused");
+ require(helmet.mayPickup(creative), "Creative curse exemption");
+ helmet.set(ItemStack.EMPTY);
+ inventory.clearContent();
+ menu.setCarried(new ItemStack(Items.COBBLESTONE, 12));
+ menu.clicked(offhand.index, 0, ClickType.PICKUP, player);
+ require(inventory.getItem(40).getCount() == 12, "Place offhand stack");
+ menu.quickMoveStack(player, offhand.index);
+ require(
+ inventory.getItem(40).isEmpty() && inventory.countItem(Items.COBBLESTONE) == 12,
+ "Offhand shift-click conserves items");
+ inventory.clearContent();
+ for (int index = 0; index < 36; index++)
+ inventory.setItem(index, new ItemStack(Items.STONE, 64));
+ entity.items.setStackInSlot(1, new ItemStack(Items.IRON_INGOT, 5));
+ require(menu.quickMoveStack(player, 1).isEmpty(), "Full inventory refuses output");
+ require(
+ offhand.getItem().isEmpty() && entity.items.getStackInSlot(1).getCount() == 5,
+ "Output does not spill into offhand");
+ inventory.setItem(0, ItemStack.EMPTY);
+ require(
+ !menu.quickMoveStack(player, 1).isEmpty() && inventory.getItem(0).getCount() == 5,
+ "Output still moves to hotbar");
+ inventory.clearContent();
+ }
+
+ private static Slot slot(MachineMenu menu, Player player, int index) {
+ return menu.slots.stream()
+ .filter(s -> s.container == player.getInventory() && s.getContainerSlot() == index)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) throw new AssertionError(message);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java
new file mode 100644
index 00000000..692f64aa
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java
@@ -0,0 +1,90 @@
+package zone.moddev.mc.lib.gui;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout;
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.layout.SideDrawerLayout;
+
+class ContainerDrawerLayoutTest {
+ @Test
+ void equipmentPairsStayTogetherAndUseASeparateTab() {
+ var panel = new Rect(220, 30, 200, 274);
+ for (int slots : new int[] {2, 6, 34, 4096}) {
+ var layout = ContainerDrawerLayout.equipment(panel, 400, slots, true);
+ var backpack = ContainerDrawerLayout.of(panel, 400, 27);
+ assertEquals(backpack.body().width(), layout.body().width());
+ assertEquals(backpack.body().x(), layout.body().x());
+ assertEquals(panel.y() + SideDrawerLayout.MARGIN, layout.body().y());
+ assertEquals(panel.height() - 2 * SideDrawerLayout.MARGIN, layout.body().height());
+ // A tall text or automation drawer uses exactly the same vertical bounds.
+ var content =
+ SideDrawerLayout.body(
+ panel,
+ 400,
+ panel.right() + 22,
+ panel.y() + SideDrawerLayout.MARGIN,
+ 150,
+ Integer.MAX_VALUE);
+ assertEquals(content.y(), layout.body().y());
+ assertEquals(content.bottom(), layout.body().bottom());
+ assertEquals(backpack.tab().y(), layout.tab().bottom());
+ assertEquals(0, layout.pageSize() % 2);
+ assertTrue(layout.pageSize() <= 54);
+ for (int i = 0; i < layout.pageSize(); i += 2) {
+ assertEquals(layout.cell(i).y(), layout.cell(i + 1).y());
+ assertTrue(layout.cell(i).right() < layout.cell(i + 1).x());
+ assertTrue(layout.cell(i + 1).right() <= layout.body().right() - 6);
+ assertTrue(layout.cell(i).bottom() <= layout.previous().y());
+ }
+ }
+ var alone = ContainerDrawerLayout.equipment(panel, 400, 2, false);
+ assertEquals(panel.bottom() - SideDrawerLayout.MARGIN, alone.tab().bottom());
+ assertEquals(
+ 0, ContainerDrawerLayout.equipment(new Rect(120, 30, 200, 274), 400, 10, false).pageSize());
+ }
+
+ @Test
+ void storageStaysOnScreenAndLeftOfTheStationaryPanel() {
+ for (int left : new int[] {150, 220, 350}) {
+ for (int bottom : new int[] {100, 180, 300}) {
+ for (int slots : new int[] {1, 27, 54, 120, 4096}) {
+ var panel = new Rect(left, 20, 200, 280);
+ var layout = ContainerDrawerLayout.of(panel, bottom, slots);
+ assertEquals(panel.x(), layout.tab().right());
+ assertTrue(layout.tab().bottom() <= bottom);
+ assertTrue(layout.pageSize() >= 1 && layout.pageSize() <= 54);
+ assertEquals(6, layout.columns());
+ assertEquals(panel.y() + SideDrawerLayout.MARGIN, layout.body().y());
+ assertEquals(layout.body().y(), layout.rail().y());
+ assertEquals(layout.body().height(), layout.rail().height());
+ assertTrue(layout.body().x() >= 4 && layout.body().y() >= 4);
+ assertEquals(layout.tab().x(), layout.body().right());
+ assertTrue(layout.previous().right() <= layout.next().x());
+ assertEquals(
+ Math.min(panel.bottom() - SideDrawerLayout.MARGIN, bottom), layout.body().bottom());
+ for (int i = 0; i < layout.pageSize(); i++) {
+ var cell = layout.cell(i);
+ assertTrue(cell.x() >= layout.body().x() + 4);
+ assertTrue(cell.right() <= layout.body().right() - 4);
+ assertTrue(cell.bottom() <= layout.previous().y());
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ void crampedWindowsKeepTheTabWithoutOffscreenSlots() {
+ for (var panel :
+ new Rect[] {
+ new Rect(30, 10, 200, 180), new Rect(120, 10, 200, 180), new Rect(200, 4, 200, 40)
+ }) {
+ var layout = ContainerDrawerLayout.of(panel, panel.bottom(), 100);
+ assertEquals(0, layout.pageSize());
+ assertEquals(0, layout.body().width());
+ assertTrue(layout.tab().x() >= 0);
+ }
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java b/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java
index 35abbc1a..86c5b5c9 100644
--- a/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java
+++ b/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java
@@ -99,9 +99,9 @@ void sharesExactlyOneRowAndReservesRightHandControls() throws Exception {
var inventory = with.inventory().orElseThrow();
var pause = with.bounds(screen.find("pause").orElseThrow());
assertEquals(inventory.x(), row.x());
- assertEquals(inventory.right(), pause.right());
+ assertEquals(ScreenLayout.playerSlot(inventory, 17).right(), pause.right());
assertEquals(row.y(), pause.y());
assertEquals(pause.x(), row.right() + ScreenLayout.GAP);
- assertEquals(row.bottom() + ScreenLayout.GAP, inventory.y());
+ assertEquals(row.bottom() + ScreenLayout.GAP, ScreenLayout.playerSlot(inventory, 9).y());
}
}
diff --git a/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java
new file mode 100644
index 00000000..b7236b8b
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java
@@ -0,0 +1,78 @@
+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.ArrayList;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.layout.ScreenLayout;
+import zone.moddev.mc.lib.gui.layout.TextMeasure;
+import zone.moddev.mc.lib.gui.load.ScreenReader;
+
+class PlayerInventoryLayoutTest {
+ @Test
+ void automaticAndExplicitInventoriesKeepAllSlotsInsideWithoutOverlap() throws Exception {
+ for (String body :
+ new String[] {
+ "",
+ "",
+ ""
+ }) {
+ for (boolean automation : new boolean[] {false, true}) {
+ var layout = layout("", body, automation);
+ var inventory = layout.inventory().orElseThrow();
+ var slots = new ArrayList();
+ for (int index = 0; index <= 40; index++) {
+ var slot = ScreenLayout.playerSlot(inventory, index);
+ assertTrue(slot.x() >= inventory.x() && slot.y() >= inventory.y());
+ assertTrue(slot.right() <= inventory.right() && slot.bottom() <= inventory.bottom());
+ for (var other : slots)
+ assertFalse(
+ slot.x() < other.right()
+ && slot.right() > other.x()
+ && slot.y() < other.bottom()
+ && slot.bottom() > other.y());
+ slots.add(slot);
+ }
+ assertEquals(slots.get(0).y(), slots.get(40).y(), "Offhand aligns with hotbar");
+ assertTrue(slots.get(39).y() < slots.get(38).y());
+ assertTrue(slots.get(38).y() < slots.get(37).y());
+ assertTrue(slots.get(37).y() < slots.get(36).y());
+ if (automation) {
+ var toolbar = layout.inventoryColors().orElseThrow();
+ assertEquals(slots.get(39).y(), toolbar.y());
+ assertEquals(slots.get(9).y(), toolbar.bottom() + ScreenLayout.GAP);
+ assertEquals(slots.get(17).right(), toolbar.right());
+ }
+ }
+ }
+ }
+
+ @Test
+ void inventoryNoneOmitsEquipmentToo() throws Exception {
+ assertTrue(layout(" inventory=\"none\"", "", false).inventory().isEmpty());
+ }
+
+ private static ScreenLayout layout(String attributes, String body, boolean automation)
+ throws Exception {
+ String xml =
+ "";
+ var screen =
+ ScreenReader.read(
+ ResourceLocation.parse("test:equipment"),
+ "equipment.xml",
+ new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+ return ScreenLayout.of(screen, TextMeasure.NONE, automation);
+ }
+}
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 6a14c82a..fbe7aa0c 100644
--- a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java
+++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java
@@ -347,8 +347,10 @@ void companionControlsSitAboveInventoryAtRightMargin() throws Exception {
layout.bounds(
screen.find(name.equals("dual_crusher") ? "toggle_1" : "toggle_0").orElseThrow());
require(controls.y() > layout.bounds(screen.find("energy").orElseThrow()).bottom());
- require(controls.bottom() < layout.inventory().orElseThrow().y());
- require(pause.right() == layout.inventory().orElseThrow().right());
+ var inventory = layout.inventory().orElseThrow();
+ require(controls.y() == ScreenLayout.playerSlot(inventory, 39).y());
+ require(controls.bottom() + ScreenLayout.GAP == ScreenLayout.playerSlot(inventory, 9).y());
+ require(pause.right() == ScreenLayout.playerSlot(inventory, 17).right());
var colours = layout.inventoryColors().orElseThrow();
require(colours.x() == layout.inventory().orElseThrow().x());
require(colours.y() == redstone.y() && colours.height() == redstone.height());
@@ -429,6 +431,40 @@ void crusherPlacesProcessingBeforeRightEnergyGauge() throws Exception {
require(energy.bottom() < layout.inventory().orElseThrow().y());
}
+ @Test
+ void settingsExpandAndCollapseThePanelAndInventory() throws Exception {
+ String name = "crusher";
+ try (var input =
+ java.nio.file.Files.newInputStream(
+ java.nio.file.Path.of("src/companion/resources/mmdlib/screen/" + name + ".xml"))) {
+ var screen =
+ ScreenReader.read(
+ ResourceLocation.parse("mmdlib_companion:" + name),
+ name + ".xml",
+ input,
+ companionLayoutWidgets());
+ TextMeasure text = t -> t.fallback().orElse(t.key()).length() * 6;
+ var compact = ScreenLayout.of(screen, text, true);
+ var expanded = ScreenLayout.of(screen, text, true, java.util.Map.of("panel", "settings"));
+ var collapsed = ScreenLayout.of(screen, text, true, java.util.Map.of("panel", "machine"));
+ var context = screen.find("panel").orElseThrow();
+ int growth = expanded.panel().height() - compact.panel().height();
+ org.junit.jupiter.api.Assertions.assertTrue(growth > 0, name);
+ require(expanded.bounds(context).height() - compact.bounds(context).height() == growth);
+ require(
+ expanded.inventory().orElseThrow().y() - compact.inventory().orElseThrow().y() == growth);
+ require(compact.panel().equals(collapsed.panel()));
+ for (var element : screen.elements())
+ require(compact.bounds(element).equals(collapsed.bounds(element)));
+ for (var layout : java.util.List.of(compact, expanded)) {
+ var inventory = layout.inventory().orElseThrow();
+ require(inventory.bottom() == layout.panel().bottom() - ScreenLayout.PANEL_MARGIN);
+ require(
+ layout.bounds(screen.find("inventory_controls").orElseThrow()).y() == inventory.y());
+ }
+ }
+ }
+
private static WidgetTypes companionLayoutWidgets() {
// These checks only measure XML. The actual companion contract is exercised by game tests.
var types = new WidgetTypes();
@@ -451,9 +487,11 @@ private static void checkLayout(Screen screen) throws Exception {
// Six pixels per character stands in for the font.
var layout = ScreenLayout.of(screen, text -> text.key().length() * 6);
var panel = layout.panel();
- require(panel.width() >= 176);
+ require(panel.width() >= ScreenLayout.INVENTORY_WIDTH + 14);
var inventory = layout.inventory().orElseThrow();
- require(inventory.x() == (panel.width() - 162) / 2 && inventory.bottom() == panel.height() - 7);
+ require(
+ inventory.x() == (panel.width() - ScreenLayout.INVENTORY_WIDTH) / 2
+ && inventory.bottom() == panel.height() - 7);
// A spacer pushes the rest of the bottom row against the root vbox's 7 px padding.
var label =
screen.elements().stream()
@@ -461,11 +499,12 @@ private static void checkLayout(Screen screen) throws Exception {
.findFirst()
.orElseThrow();
require(layout.bounds(label).right() == panel.width() - 7);
- // Every view of a context area sits at the same place, sized to the largest.
+ // The context measures its default view; hidden views retain independent bounds.
var context = (Element.Context) screen.find("main").orElseThrow();
var machine = layout.bounds(context.views().get(0));
var sides = layout.bounds(context.views().get(1));
- require(machine.equals(sides) && machine.equals(layout.bounds(context)));
+ require(machine.equals(layout.bounds(context)));
+ require(machine.x() == sides.x() && machine.y() == sides.y());
// The upgrade tab overlaps the panel's border; its slots start inside the panel's right edge.
var tab = layout.bounds(screen.tabs().get(0));
require(tab.x() == panel.width() - 4);
diff --git a/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java
new file mode 100644
index 00000000..a6e73b79
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java
@@ -0,0 +1,27 @@
+package zone.moddev.mc.lib.gui;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+import zone.moddev.mc.lib.gui.layout.Rect;
+import zone.moddev.mc.lib.gui.layout.SideDrawerLayout;
+
+class SideDrawerLayoutTest {
+ @Test
+ void growingContentFitsBeforeItNeedsScrolling() {
+ var panel = new Rect(200, 40, 198, 200);
+ var full =
+ SideDrawerLayout.body(panel, 450, 420, 44, 112, 300, SideDrawerLayout.HeightLimit.SCREEN);
+ assertEquals(300, full.height());
+ assertEquals(44, full.y());
+ assertTrue(full.bottom() > panel.bottom());
+
+ var clipped =
+ SideDrawerLayout.body(panel, 280, 420, 44, 112, 300, SideDrawerLayout.HeightLimit.SCREEN);
+ assertEquals(280, clipped.bottom());
+ assertEquals(full.y(), clipped.y());
+
+ var inventory = SideDrawerLayout.body(panel, 450, 58, 44, 120, 300);
+ assertEquals(panel.bottom() - SideDrawerLayout.MARGIN, inventory.bottom());
+ }
+}