An inventory GUI library for Paper and Folia.
public final class MyPlugin extends JavaPlugin {
private Trestle trestle;
@Override
public void onEnable() {
trestle = Trestle.create(this);
}
@Override
public void onDisable() {
trestle.shutdown();
}
}public final class HomeMenu extends AbstractPanel {
public HomeMenu(Trestle trestle, Player viewer) {
super(trestle, viewer, MenuKind.chest(3), MiniText.plain("<dark_gray>Home"));
setAll(Slots.border(27), Elements.pane(trestle.items(), Material.GRAY_STAINED_GLASS_PANE));
set(13, StaticElement.button(icon, context -> context.viewer().sendMessage("hi")));
}
}
trestle.panels().openRoot(player, new HomeMenu(trestle, player));Most menu libraries rebuild the whole inventory whenever anything changes. Click "next page" on a 54 slot menu and you get 54 new ItemStack objects, 54 setItem calls and a MiniMessage parse for every lore line, including the 20 slots that look exactly the same as they did a second ago.
Trestle tracks which slots changed and only touches those. Slots that need redrawing are bits in a long, so a 54 slot menu fits in one word. An idle menu costs a single comparison per flush. A page turn writes the slots whose icon actually differs and skips the rest.
Nothing polls. An element that changes calls markDirty() on the slots it occupies, and the next flush visits exactly those. There is no per-tick loop asking every slot whether it is stale.
Two other things that fall out of the design:
- Clicks are routed by
event.getInventory().getHolder(), so there is no map lookup on the click path and no chance of the lookup disagreeing with the inventory that is actually open. - Everything that touches an inventory goes through a scheduler bridge built on Paper's entity and region schedulers, which exist on Paper and Folia both. There is no
if (folia)anywhere in the code.
Panels: AbstractPanel for a plain menu, PagedPanel, ScrollPanel, TabPanel, and ConfirmPanel for a yes/no prompt.
Elements, which are what fills a slot: static, dynamic, action-bound, toggle, async, and input slots that a player can actually put items into. You can write your own; the interface is two methods.
Container shapes beyond chests: hopper, dropper, dispenser, barrel, shulker box, ender chest, and the station types (furnace, brewing stand, crafting table, anvil, grindstone, stonecutter, cartography table, smithing table, loom, beacon, enchanting table), plus MenuKind.of for anything else the server can create.
Player heads with custom skins from a base64 texture, a texture id or a URL, cached so the same head across three hundred viewers is built once.
Content sources, so a paged menu asks "how many rows" and "what is row N" instead of holding the whole list. Includes an async source that shows a loading state until a future resolves, and ContentView for filtering and sorting without keeping a second list in sync by hand.
YAML layouts with a character mask, named regions and anchors, slot ranges, and row/column addressing. Action keys are checked at startup so a typo shows up in console instead of as a button that does nothing.
Resource pack support: item_model and custom_model_data on layout items, tooltip suppression, and helpers for putting a pack's GUI background glyph in the inventory title with the right offsets.
A navigation stack, so context.back() returns the viewer to where they came from, on the page they left it on.
Menus are read only by default. The click listener cancels everything first and then re-permits what a panel explicitly declared interactive. That ordering matters: if an element throws halfway through handling a click, the click stays cancelled.
Some inventory actions write to slots the player never clicked, so they are handled individually rather than by a blanket "cancel the clicked slot" check:
- Double-click collect (
COLLECT_TO_CURSOR) sweeps every matching stack out of both inventories. Blocked outright while a panel is open. - Shift-clicking an item into the menu lets vanilla pick the destination slot, which a panel cannot constrain. Cancelled, and the panel is told about it through
handleShiftInsertso it can place the item deliberately. - Number key and offhand swaps are blocked unless the target slot is interactive.
- Drags are cancelled if they touch any non-interactive panel slot.
Trestle.shutdown() closes every open panel before unregistering the listener. Without that, a menu left open across a /reload becomes an ordinary chest full of real items, which is the most reliable way to dupe through a menu plugin.
Layout files are treated as untrusted input: slot ranges are clamped, sizes validated, lore capped so an oversized window packet cannot disconnect the player.
| Server | Paper 1.21.x or newer, or Folia |
| Java | 21 or newer |
| Adventure / MiniMessage | comes with Paper, nothing to add |
| Jar size | 91 KB, and you do not have to bundle it |
Spigot and CraftBukkit are not supported. Trestle uses Paper's schedulers and Adventure directly, and there is no fallback path for either.
Compiled against paper-api:1.21.8. The few methods that moved on the 26.x line are reached through MethodHandles resolved once at class init, so the same jar covers both.
repositories {
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://jitpack.io")
}
dependencies {
implementation("com.github.EliteDenizen:Trestle:v0.1.0")
}Maven, and the two ways of getting the classes onto a server, are in PUBLISHING.md. Short version: either shade it (91 KB, less with minimize()) or let Paper download it at startup and bundle nothing.
public final class ShopMenu extends PagedPanel<Listing> {
private final ShopService shop;
public ShopMenu(Trestle trestle, Player viewer, ShopService shop) {
super(trestle, viewer, MenuKind.chest(6),
MiniText.plain("<dark_gray>Shop"), Slots.inner(54));
this.shop = shop;
setAll(Slots.border(54), Elements.pane(trestle.items(), Material.GRAY_STAINED_GLASS_PANE));
navigation(45, 53, 49);
source(ContentSource.of(shop.listings()));
installContentElements();
}
@Override
protected ItemStack iconFor(Listing listing, int index) {
return listing.icon();
}
@Override
protected void onRowClick(Listing listing, int index, ClickContext context) {
context.open(ConfirmPanel.of(trestle, context.viewer(),
"<dark_gray>Buy this?", listing.icon(),
confirmed -> shop.buy(confirmed.viewer(), listing),
cancelled -> { }));
}
}The content elements are created once in installContentElements() and read the current page offset when asked for an icon, so turning a page allocates nothing.
If the rest of your menu changes with the page, override onWindowChanged(int). It fires on page turns, scrolls, tab switches and source swaps, and is where a header like "showing 21 to 40 of 315" or a per-tab sidebar gets updated. The framework only assumes it owns the content region; everything else is yours.
- DOCS.md covers every class, the layout format, threading, performance and the safety model in detail.
- PUBLISHING.md is publishing to GitHub and JitPack, and using it from another project.
- MAVEN-CENTRAL.md is the Maven Central route, which is what makes the
libraries:key inplugin.ymlwork. - CHANGELOG.md
0.1.0. The design has been through a full rewrite after real use, but the public API is new and the leading zero is honest: expect it to move before 1.0.
Slot parsing, placeholder compilation, the dirty bitset and the layout model have unit tests. Everything that needs a running server has been tested by hand on Paper and Folia, which is not the same thing. Unusual container types and heavy Folia region churn are the areas I would look at first if something misbehaves.
Issues and pull requests welcome. If you hit a case the API cannot express, that is worth an issue even if you have a workaround.
MIT.