A menu that can be handed an item
The GUI module could show anything and receive nothing. Every click and drag over a library menu was cancelled, unconditionally - which is exactly what makes a declarative menu safe, and what made one shape of menu inexpressible: the one that asks the player for an item. A shop asking which item you are selling. A kit editor asking what goes in slot 4. A deposit cell.
Two plugins had already paid for that gap by dropping out of the module entirely: SnDisplayShops' owner menu and SnKits' KitItemsEditor are raw Bukkit inventories with hand-rolled listeners, layouts living outside guis/, and none of what the module gives you - no view requirements, no per-click matrix, no anti-theft marker, no tenant teardown.
1.28.0 closes it with two yml keys and one callback.
The declaration is config
# guis/editor.yml
title: "&8Kit editor"
player-inventory: open # the viewer may use their own inventory
layout:
- "fffffffff"
- "ffffiffff"
- "fffffffff"
items:
slot:
key: i
input: true # THIS cell receives an item
material: LIGHT_GRAY_STAINED_GLASS_PANE
display-name: "&eDrop an item here"
click-actions: # still fires when the cursor is EMPTY
- "[message] &7Hold the item you want to place."input: true(item level, also on templates) marks the cell as an INPUT SLOT. A viewer who clicks it holding a stack, or drags a stack onto it, hands that stack to the plugin. A click with an empty cursor is not an offer and still runs the cell'sclick-actions, so one cell is a button and a drop target at once.player-inventory: locked | open(menu level, defaultlocked) decides whether the viewer may use their own inventory at all. Underopentheir plain clicks, number keys, Q drops, F swaps and drags inside their own inventory work again - stack splitting used to fail silently - and a shift-click there becomes an offer too, because a shift-click aims INTO the menu and only the plugin can decide what that means.
The two are orthogonal and both default to the old behaviour. The pair is checked at parse: an input cell with a locked player inventory WARNs, because a viewer who can never pick a stack up can never offer one.
The Java side is one callback
GuiSession s = gui.session(player);
s.onOffer(offer -> {
kit.setIcon(offer.stack()); // a copy, with its real amount
s.bind(offer.slot(), gui.template("filled"), offer.stack());
});public record ItemOffer(Player viewer, Kind kind, int slot, int playerSlot,
ItemStack stack, ClickType click) {
public enum Kind { CURSOR, DRAG, SHIFT_CLICK }
}| Kind | slot() |
playerSlot() |
click() |
|---|---|---|---|
CURSOR |
the input cell clicked | -1 |
LEFT or RIGHT |
DRAG |
the single input cell covered | -1 |
RIGHT for a single-item drag, LEFT for an even spread |
SHIFT_CLICK |
-1 |
the player-inventory slot the stack came from | SHIFT_LEFT / SHIFT_RIGHT |
click() is carried so the vanilla convention stays expressible: right deposits one, left deposits the stack. stack() always carries its real amount, which is what a deposit needs.
The guarantee: the item is READ, never consumed
Every event behind an offer is cancelled before the handler runs, and the offer carries a defensive clone. The cancel itself is what puts the stack back on the cursor or in the inventory. SnLib does not move it, shrink it, delete it, store it, or write it into the menu.
That line is deliberate. How much of a stack you accept, where it goes and what the cell then shows are consumer decisions, and a library that guessed them would own the money-shaped half of every deposit flow. playerSlot() is what lets the consumer write the remainder back itself:
s.onOffer(offer -> {
if (offer.kind() != ItemOffer.Kind.SHIFT_CLICK) {
return;
}
ItemStack offered = offer.stack();
int accepted = vault.deposit(player, offered);
if (accepted <= 0) {
sn.lang().send(player, "vault.full");
return;
}
ItemStack remainder = offered.getAmount() > accepted
? offered.asQuantity(offered.getAmount() - accepted)
: null;
player.getInventory().setItem(offer.playerSlot(), remainder);
player.updateInventory(); // the ghost-stack resend, see below
s.refreshMenu();
});Always follow a write-back with updateInventory(). The click was cancelled, so the client is still drawing the stack it had before the event; when you then change that slot server-side, the client keeps painting a stale stack the player can appear to click on. One resend fixes it. It is documented on the record, on the developer page and on the admin page, because it is the single most common way to get this wrong.
The routing is a pure, exhaustively tested table
GuiClickListener is now a thin adapter over a new pure core, gui/internal/OfferRouting: zone + policy + action + click + three booleans in, one Decision out.
| Situation | Decision |
|---|---|
Action is COLLECT_TO_CURSOR, any zone, any policy, input cell or not |
CANCEL_ONLY |
| TOP cell, input, cursor non-empty, click LEFT or RIGHT | OFFER_CURSOR |
| TOP cell, anything else | CANCEL_AND_CLICK |
| OUTSIDE the window, either policy | CANCEL_ONLY |
BOTTOM, policy LOCKED |
CANCEL_ONLY |
BOTTOM, policy OPEN, shift over a non-empty stack |
OFFER_SHIFT |
BOTTOM, policy OPEN, anything else (incl. a shift over an EMPTY slot) |
PASS_THROUGH |
DRAG covering no menu cell, policy OPEN |
PASS_THROUGH |
DRAG covering no menu cell, policy LOCKED |
CANCEL_ONLY |
| DRAG covering exactly ONE input cell, stack non-empty | OFFER_DRAG |
| DRAG, anything else (2+ cells even if all input; one non-input cell; empty cursor) | CANCEL_ONLY |
Two invariants carry everything else, and both are asserted directly:
COLLECT_TO_CURSOR(the double-click gather) is cancelled first, unconditionally, under both policies, input cell or not. It is the one action that pulls stacks out of the TOP inventory while the click that fires it lands on the bottom one.- A click on a cell of the menu is always cancelled - as a plain click or as an offer, it makes no difference. No rendered stack can therefore reach the cursor, which is precisely what makes leaving the player's own inventory alone safe.
Deliberate divergences worth knowing: a shift-click over an EMPTY bottom slot passes through rather than being cancelled (vanilla does nothing with it, and cancelling a no-op only costs a client desync); a drag spread over several input cells is cancelled rather than split, because inventing a split would be SnLib deciding how much each cell gets; and offers never pass through strict-clicks, which filters ACTIONS - an offer is not one.
GuiProtectionListener is untouched. Nothing in the new paths stamps, writes or moves a stack anywhere.
Compatibility
- Strictly additive. Every new yml key defaults to the pre-1.28.0 behaviour, and a menu that declares neither is byte-identical in what it cancels and what it dispatches.
OfferRoutingTestasserts exactly that for everyClickTypein every zone and for everyInventoryAction, not as a sample. SnChat'sSnapshotGuianti-dupe guarantee and SnCrates'RewardListViewHIGHEST bottom-click handler both rest on that property and both keep working unchanged. - New public surface only:
PlayerInventoryPolicy, theItemOfferrecord and itsKindenum,GuiItemDef.input(),GuiDef.playerInventory(), andGuiSession.onOffer/handleOffer/isInputSlot/playerInventory. No removals, no changed signatures. - japicmp additive-only gate passes against the
com.sn:snlib:1.0.0baseline. SnApi.LEVEL18 -> 19. Consumers built against LEVEL 18 keep running against this jar unchanged; a consumer that uses the new API needs SnLib 1.28.0 or newer installed, and the handshake tells it so at enable time instead of failing later.- 532 tests green (508 before, 24 new).
Also in this release
- The GitBook LEVEL history had drifted four releases behind (it stopped at 13 and claimed 13 was the current value). It is filled in through 19 from the
SnApijavadoc, which is the source of truth. docs/consumer-pom-template.xmlmoves off its stale1.21.1snlib pin.