Skip to content

GUIs — Overview

The GUI system is built on Bukkit’s InventoryHolder pattern. Every menu holds its own Inventory, handles its own click events, and is tracked per-player — no global slot maps, no hard-coded switch statements.

Class Role
Menu Abstract base for all GUIs — title, size, items, template, click dispatch
@MenuMeta Class annotation declaring a menu’s title / size / permission (like @CommandMeta)
MenuButton A permission-aware slot node: icon + click handler + permission + visibility predicate
PaginatedMenu Extends Menu with automatic page layout and navigation buttons
MenuItem An ItemStack paired with an optional click handler (Consumer<InventoryClickEvent>)
PlayerMenuContext Per-player object holding state, arbitrary data, and navigation history
MenuManager Service — manages PlayerMenuContext instances and closes menus on shutdown
MenuRegistry Service — registers menus by key and opens them by name (like CommandRegistry)
MenuFactory Functional interface that builds a Menu for a context (e.g. ShopMenu::new)
MenuListener Routes InventoryClickEvent/InventoryDragEvent to the correct Menu

A menu is declared the same way a command is: metadata in an annotation, content declared fluently. Annotate the class with @MenuMeta (so you no longer override title()/size()), then declare each slot with the fluent, permission-aware button(int) API inside decorate():

@MenuMeta(title = "<dark_purple>Example Shop", size = 27, permission = "core.shop")
public final class ShopMenu extends Menu {
public ShopMenu(CorePlugin plugin, PlayerMenuContext context) {
super(plugin, context);
}
@Override
protected MenuTemplate template() {
return Templates.bordered();
}
@Override
protected void decorate() {
button(13)
.icon(new ItemBuilder(Material.DIAMOND).name("<aqua>Buy a diamond").build())
.onClick(event -> context.player().sendMessage(ColorUtils.parse("<green>Bought!")))
.add();
button(15)
.icon(new ItemBuilder(Material.CHEST).name("<gold>Restock").build())
.permission("core.shop.admin") // hidden + click-guarded for players without it
.onClick(event -> restock())
.add();
}
}

A button with a permission (or a visibleIf predicate) is auto-hidden during render for players who fail it, and its click is guarded even if somehow triggered — mirroring how the command tree hides subcommands in tab-complete and re-checks permission before running. Because a hidden button leaves its slot empty, put a template filler underneath if your layout assumes a fixed grid.

@MenuMeta is optional and additive: existing menus that override title()/size() and use set(slot, MenuItem.of(...)) keep working unchanged.

public final class MyMenu extends Menu {
public MyMenu(CorePlugin plugin, PlayerMenuContext context) {
super(plugin, context);
}
@Override
public Component title() {
return ColorUtils.parse("<dark_purple><bold>My Menu");
}
@Override
public int size() {
return 27; // must be a multiple of 9
}
@Override
protected MenuTemplate template() {
return Templates.bordered(); // optional; null = no template
}
@Override
protected void decorate() {
set(13, MenuItem.of(
new ItemBuilder(Material.EMERALD)
.name("<green>Click me!")
.lore("<gray>Does something cool")
.build(),
event -> context.player().sendMessage(
ColorUtils.parse("<green>You clicked!"))));
}
}

Always open through MenuManager to ensure the context exists:

MenuManager menus = service(MenuManager.class);
PlayerMenuContext context = menus.context(player);
new MyMenu(plugin, context).open();

Or from a command:

@Override
public void run(CommandContext context, Arguments args) throws CommandException {
new MyMenu(plugin, menus.context(context.player())).open();
}
// Non-interactive display item
MenuItem.display(itemStack)
// Interactive item with click handler
MenuItem.of(itemStack, event -> { /* handle click */ })

Clicks are cancelled by default to prevent item theft — except on declared input slots where the player is allowed to place and take items.

PlayerMenuContext maintains a navigation stack:

// From inside a menu or click handler:
back() // reopen the previous menu, or close if history is empty
close() // close the inventory
refresh() // rebuild the menu in place (useful after state changes)

PlayerMenuContext — sharing data between menus

Section titled “PlayerMenuContext — sharing data between menus”
// In the first menu
context.set("selectedItem", someItem);
// In the next menu
ItemStack item = context.get("selectedItem");

MenuManager creates and caches one PlayerMenuContext per player. When the plugin disables, it calls closeAll() to close every open menu:

MenuManager menus = service(MenuManager.class);
menus.context(player); // get or create context
menus.forget(player); // remove context (call on PlayerQuitEvent)
menus.closeAll(); // close all open menus

MenuListener is registered automatically by MenuModule. It intercepts all InventoryClickEvents, checks if the top inventory’s holder is a Menu, and delegates to menu.handleClick(event). It also routes InventoryCloseEvent to onClose. Drag events in menus are cancelled unless every affected slot is an input slot.

By default a menu is read-only. To let the player place and take an item in a specific slot — an item-editor GUI, an enchanting/upgrade input, an anvil-style rename — declare it as an input slot from decorate():

@Override
protected void decorate() {
setItem(11, MenuItem.display(new ItemBuilder(Material.ARROW).name("<gray>Put an item ->").build()));
inputSlot(12); // slot 12 is freely editable by the player
}

Clicks and drags into an input slot are not cancelled, so the item moves normally. Input slot declarations are cleared and re-applied on every open()/refresh().

Override onClose(InventoryCloseEvent) to react when the menu is closed — most importantly to return an item left in an input slot so it is never lost:

private static final int INPUT = 12;
@Override
protected void onClose(InventoryCloseEvent event) {
ItemStack left = inventory.getItem(INPUT);
if (left == null || left.getType().isAir()) {
return;
}
inventory.setItem(INPUT, null);
Player player = context.player();
player.getInventory().addItem(left).values()
.forEach(overflow -> player.getWorld().dropItemNaturally(player.getLocation(), overflow));
}