Inventory menu framework for Paper 1.21.1+ built with Java 21
Fluent API · Pagination · Dynamic Content · Caching · Sessions · Null-Safety
- Overview
- Requirements
- Installation
- Quick Start
- Public API Surface
- Builder Reference
- Item Templates
- Click Context
- Pagination
- Dynamic Content
- Permissions, Cooldowns, Toggles, and Player Inventory
- Menu Features
- Preloading
- Sessions and Metrics
- Configuration
- Architecture
- Performance and Safety
- Development
- Changelog
- License
MenuFramework is a reusable inventory GUI framework for Paper plugins. It provides a fluent builder for menus, item templates, paginated dynamic content, click routing, player session lifecycle, cache-backed rendering, and optional interaction helpers such as permissions, cooldowns, toggle slots, menu history, sounds, and preloading.
The project targets a modern Java style:
- Java 21 records, pattern matching,
CompletableFuture, and immutable definitions. - JSpecify annotations for explicit null-safety.
- Early-return control flow in production code; no
elsebranches insrc/main/java. - Defensive boundaries for user-provided dynamic content.
- SpotBugs and JUnit coverage for regressions.
- Java 21+
- Paper API 1.21.1+
- Gradle wrapper included in the repository
MenuFramework is published via JitPack and GitHub Releases.
Add JitPack and the dependency to your plugin's build.gradle:
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
maven { url = 'https://jitpack.io' }
}
dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT'
implementation 'com.github.HanielCota:MenuFramework:v1.0.0'
}Note: MenuFramework is published as a fat-jar — it already includes Caffeine and FastUtil. You only need the single dependency above.
Important: Always shade and relocate MenuFramework to avoid conflicts with other plugins:
shadowJar {
relocate 'com.github.hanielcota.menuframework', 'yourplugin.libs.menuframework'
archiveClassifier.set('')
}
tasks.build.dependsOn tasks.shadowJarReplace
yourpluginwith your plugin's package (e.g.,me.haniel.myplugin).You no longer need to relocate
caffeineorfastutilseparately — they are already bundled inside MenuFramework.
For a complete step-by-step guide, see USAGE.md.
public final class MyPlugin extends JavaPlugin {
private MenuService menus;
@Override
public void onEnable() {
menus = MenuFramework.create(this);
registerMenus();
}
@Override
public void onDisable() {
menus.shutdown();
}
}You can also use singleton mode:
MenuService menus = MenuFramework.initialize(plugin);
MenuFramework.builder("main"); // uses the singleton service
MenuFramework.shutdown();private void registerMenus() {
ItemTemplate filler = ItemTemplate.builder(Material.BLACK_STAINED_GLASS_PANE)
.name(" ")
.build();
ItemTemplate shop = ItemTemplate.builder(Material.EMERALD)
.name("<green>Shop")
.lore(List.of(Component.text("Open the shop")))
.glow(true)
.build();
MenuFramework.builder("main", menus)
.rows(3)
.title("<green>Main Menu")
.fillEmpty(filler)
.slot(13, shop, ctx -> {
ctx.reply("<gray>Opening shop...");
ctx.open("shop");
})
.build()
.register();
}menus.open(player, "main")
.exceptionally(error -> {
player.sendMessage(Component.text("Could not open menu."));
return null;
});open(...) returns CompletableFuture<MenuSession> because session creation is bridged onto the server thread.
| Type | Purpose |
|---|---|
MenuFramework |
Entry point for standalone or singleton service creation. |
MenuService |
Main runtime API composed from definition, template, dynamic content, opening, session, diagnostics, and preloader services. |
MenuBuilder |
Fluent menu definition builder. |
MenuRegistrar |
Result of build(), used to register the definition and dynamic content. |
MenuSession |
Active player menu session; exposes page, refresh, close, dispose, and slot update operations. |
ClickContext |
Callback context for slot clicks, including navigation and messaging helpers. |
ItemTemplate |
Immutable item blueprint used by the cached ItemStack factory. |
SlotDefinition |
Slot item, handler, permission, cooldown, navigation, and toggle metadata. |
PaginationConfig |
Content and navigation slot configuration for paginated menus. |
MenuPreloader |
Async pre-computation API for menu content and cached pages. |
MenuFramework.builder("menu-id", menus)
.title("<gold>Menu Title")
.rows(6)
.layout(
"XXXXXXXXX",
"X X",
"XXXXXXXXX")
.bind('X', filler)
.slot(13, item, ctx -> ctx.reply("<green>Clicked"))
.navigational(45, backButton, ctx -> ctx.back())
.slotWithCooldown(20, button, handler, 20)
.slotWithPermission(22, adminButton, handler, "plugin.admin", deniedButton)
.toggleSlot(24, enabledItem, disabledItem, true, (ctx, enabled) -> {
ctx.reply(enabled ? "<green>Enabled" : "<red>Disabled");
})
.allowPlayerInventoryClicks(true)
.allowShiftClick(false)
.onPlayerInventoryClick((player, clickType, slot, session) -> {
player.sendMessage(Component.text("Clicked player inventory slot " + slot));
})
.fillBorder(filler)
.fillEmpty(filler)
.feature(MenuFeatures.soundOnOpen(sound))
.dynamicContent((player, session) -> List.of())
.build()
.register();Important builder notes:
build()is single-use. Reuse requires a newMenuBuilder.slot(...)accepts a nullable template and ignores null templates by design.navigational(...)registers slots that participate in active click handling even when used as pagination/navigation controls.fillEmpty(...)stores a menu-wide filler rendered into empty inventory slots.fillBorder(...)andfillPattern(...)write static slots only where no slot is already configured.MenuBuilder.SlotPatternis deprecated; preferbuilder.pattern.*strategy classes ordefinition.SlotPatternfor pagination content slots.
ItemTemplate is the only item type accepted by the framework. It is immutable, cacheable, and cloned defensively when converted to an ItemStack.
ItemTemplate button = ItemTemplate.builder(Material.PLAYER_HEAD)
.name("<yellow>Profile")
.lore(List.of(Component.text("Click to inspect")))
.amount(1)
.glow(true)
.customModelData(1001)
.head(player.getUniqueId())
.clickSound(org.bukkit.Sound.UI_BUTTON_CLICK)
.build();Supported template fields:
- Material, display name, lore, item flags, amount, glow, custom model data.
- Player heads by UUID or base64 texture.
- Leather armor color.
- Persistent data container values for common primitive types.
- Optional Bukkit click sound stored on the template.
Handlers receive ClickContext, which combines player, slot, click type, navigation, messaging, and session access.
ctx -> {
Player player = ctx.player();
ClickType click = ctx.clickType();
if (click.isRightClick()) {
ctx.reply("<gray>Right click on slot " + ctx.slot());
return;
}
ctx.setPage(ctx.currentPage() + 1);
}Useful methods:
player(),audience(),clickType(),slot(),rawSlot(),session()reply(Component),reply(String),plugin()open(menuId),back(),hasPreviousMenu()setPage(page),currentPage()refresh(),close()
Pagination projects a list of dynamic SlotDefinition items into configured inventory slots.
menus.registerTemplate("prev", ItemTemplate.builder(Material.ARROW)
.name("<yellow>Previous")
.build());
menus.registerTemplate("next", ItemTemplate.builder(Material.ARROW)
.name("<yellow>Next")
.build());
PaginationConfig pagination = PaginationConfig.builder()
.contentSlots(SlotPattern.BORDERED.slots(6))
.navigationSlots(List.of(45, 53))
.previousTemplate("prev")
.nextTemplate("next")
.build();
MenuFramework.builder("items", menus)
.rows(6)
.title("<aqua>Items")
.pagination(pagination)
.addItem(ItemTemplate.builder(Material.DIAMOND).name("<blue>Diamond").build(),
ctx -> ctx.reply("<blue>Diamond"))
.addItem(ItemTemplate.builder(Material.EMERALD).name("<green>Emerald").build(),
ctx -> ctx.reply("<green>Emerald"))
.build()
.register();Built-in pagination slot patterns:
SlotPattern.FULLSlotPattern.BORDEREDSlotPattern.CHEST_6
Navigation buttons use registered template IDs. If the previous or next template is missing, that navigation button is skipped.
Dynamic content can be registered statically with addItem(...) or supplied at render time with DynamicContentProvider.
MenuFramework.builder("leaderboard", menus)
.rows(6)
.pagination(PaginationConfig.builder()
.contentSlots(SlotPattern.BORDERED.slots(6))
.navigationSlots(List.of(45, 53))
.build())
.dynamicContent((player, session) -> leaderboardService.topPlayers().stream()
.map(entry -> SlotDefinition.of(
-1,
ItemTemplate.builder(Material.PLAYER_HEAD)
.name("<gold>" + entry.name())
.lore(List.of(Component.text("Score: " + entry.score())))
.build(),
ctx -> ctx.reply("<yellow>" + entry.name())))
.toList())
.build()
.register();Null-safety behavior:
- A provider returning
nullis treated as an empty list. - Null entries returned by a provider are ignored.
- Static dynamic content registered through
setDynamicContent(...)is defensively copied. - Updating dynamic content invalidates cached pages for that menu.
builder.slotWithPermission(
13,
adminItem,
ctx -> ctx.reply("<green>Admin action"),
"myplugin.admin",
deniedItem);When the viewer lacks permission, the fallback template is rendered and the protected handler is not executed.
builder.slotWithCooldown(20, expensiveActionItem, ctx -> {
ctx.reply("<green>Action executed");
}, 40); // 40 ticksThere is also a small global anti-spam cooldown in the interaction layer.
builder.toggleSlot(
22,
ItemTemplate.builder(Material.LIME_WOOL).name("<green>Enabled").build(),
ItemTemplate.builder(Material.RED_WOOL).name("<red>Disabled").build(),
false,
(ctx, enabled) -> ctx.reply(enabled ? "<green>On" : "<red>Off"));Toggle state is kept in MenuSessionState and re-applied after refreshes.
builder
.allowPlayerInventoryClicks(true)
.allowShiftClick(false)
.onPlayerInventoryClick((player, clickType, slot, session) -> {
player.sendMessage(Component.text("Bottom inventory slot: " + slot));
});By default, player inventory clicks and shift-clicks are blocked while a menu session is active.
MenuFeature exposes lifecycle hooks:
onOpen(MenuSession)onClose(MenuSession)onClick(ClickContext)onTick(MenuSession, Player)
Built-in factories currently available:
builder.feature(MenuFeatures.soundOnOpen(adventureSound));
builder.feature(MenuFeatures.soundOnClick(adventureSound));For ticking menus, implement RefreshingMenuFeature:
public record ClockFeature(long refreshIntervalTicks) implements RefreshingMenuFeature {
@Override
public void onTick(MenuSession session, Player viewer) {
session.refresh();
}
}
builder.feature(new ClockFeature(20));Note: MenuFeatures.refreshInterval(long) currently validates the tick value but does not return a feature. Use a custom RefreshingMenuFeature until that factory is adjusted.
MenuPreloader warms menu content asynchronously and pre-computes up to the first few paginated pages.
menus.preloader().preload("shop");
menus.preloader().preload(player, "leaderboard");
menus.preloader().preloadAll("shop", "leaderboard", "settings");
menus.preloader().invalidate("shop");
menus.preloader().invalidateAll();Use player-specific preload when a dynamic provider depends on Player or MenuSession.
menus.getSession(player.getUniqueId()).ifPresent(MenuSession::refresh);
MenuMetrics metrics = menus.getMetrics();
long active = metrics.activeSessions();
long menusRegistered = metrics.registeredMenus();
long cachedPages = metrics.cachedPages();Session API:
viewerId(),menuId(),view()currentPage(),setPage(int)refresh(),close(),dispose()updateSlot(int, ItemTemplate)updateSlots(Map<Integer, ItemTemplate>)
updateSlot and updateSlots require non-null templates and fail fast with NullPointerException if a caller passes null.
MenuFrameworkConfig config = new MenuFrameworkConfig()
.sessionCacheMaxSize(1_000)
.sessionCacheExpireMinutes(10)
.pageCacheMaxSize(10_000)
.pageCacheExpireMinutes(15)
.itemStackCacheMaxSize(4_000)
.itemStackCacheExpireMinutes(30)
.logSlowRenders(true)
.slowRenderThresholdMillis(50);Current API caveat: MenuFramework.create(plugin, builder) and initialize(plugin, builder) exist, but the nested MenuFramework.Builder constructor is private and there is no public factory for it. External plugin code can use default configuration today; custom configuration requires exposing a public builder factory or constructor.
Plugin
-> MenuFramework
-> MenuService
-> MenuRuntime
-> MenuRegistry
-> SessionRegistry
-> RenderEngine
-> StaticRenderStrategy
-> PaginatedRenderStrategy
-> PaginationEngine
-> SessionFactory
-> MenuEventRouter
-> MenuPreloader
Core implementation areas:
api/: public contracts for plugin developers.builder/: fluent menu construction.definition/: immutable records for menus, slots, templates, pagination, toggles.internal/render/: render strategies, page application, navigation rendering, dynamic content resolution.internal/session/: session state, renderer, click context, lifecycle, active slot registry, history.internal/interaction/andinteraction/: policies, click execution, permissions, cooldowns, sound, toggles.internal/registry/: definitions, templates, dynamic content, sessions.scheduler/: Paper scheduler abstraction.core/: cache, config, server, profile, and text utilities.
ItemTemplateobjects are converted to cached baseItemStacks, then cloned before use.PageViewclones item arrays at boundaries.- Page cache keys include
(menuId, pageNumber, contentHash). - Dynamic content updates invalidate page cache for that menu.
- Sessions are closed on inventory close, player quit, plugin disable, and service shutdown.
- Refresh tasks are canceled when sessions are disposed.
- Bukkit inventory mutation is bridged to the server thread through
SchedulerAdapter. - User-provided dynamic content is sanitized before pagination/rendering.
Run the main verification:
.\gradlew.bat test spotbugsMainOptional formatting:
.\gradlew.bat spotlessApplyCurrent local verification status:
- 161 tests, 0 failures, 0 errors, 25 skipped.
spotbugsMainpasses.rg -n "\belse\b" src\main\java src\test\javareturns no matches.
PMD is configured but currently has style-oriented findings and ignoreFailures = true.
See CHANGELOG.md for unreleased changes and version history.
See GitHub Releases for published release artifacts.