GUIs and registries
These systems cover two common extension problems: treating an inventory as an interface rather than a container, and accepting handlers from another plugin without leaving stale classes behind when that plugin disables.
A basic menu
Bootstrap creates the shared inventory listener immediately; keystone.gui() returns that existing
service. A menu subclasses GuiMenu and populates buttons in build():
public final class ConfirmMenu extends GuiMenu {
public ConfirmMenu() {
super("<gold>Confirm action", 3);
}
@Override
protected void build() {
set(11, GuiButton.of(confirmIcon(), click -> confirm(click.player())));
set(15, GuiButton.of(cancelIcon(), click -> click.player().closeInventory()));
fill(backgroundIcon());
}
}
keystone.gui();
new ConfirmMenu().open(player);
Rows are clamped to 1–6. set(slot, button) ignores an out-of-range slot, fill(item) fills only
unused slots, and clear() removes the current button map and icons.
The framework identifies menus by their InventoryHolder, not by title. It cancels inventory
clicks, creative clicks and drags before dispatching a button, preventing shift-click and hotbar
swaps from pulling interface icons into a player inventory.
Paginated menus
public final class ItemMenu extends PaginatedMenu<ItemDefinition> {
public ItemMenu() {
super("<gold>Items", 6);
}
@Override
protected List<ItemDefinition> contents() {
return registry.values().stream().toList();
}
@Override
protected GuiButton renderEntry(ItemDefinition item) {
return GuiButton.of(iconFor(item), click -> showDetails(item, click.player()));
}
}
The last row is reserved for previous/next controls and a page indicator. contents() is re-read
on every draw, and the page is clamped when the list shrinks. Override decorateNavigation() to
place filters or other controls in the final row.
Owner-aware registries
Create one registry for each public extension type:
OwnedRegistry<EffectHandler> effects = keystone.registry("effect handler");
NamespacedKey id = new NamespacedKey(addonPlugin, "launch");
Registration registration = effects.register(addonPlugin, id, handler);
register(owner, id, value)RegistrationAdd one value. The id must use the owner's namespace and duplicates throw
IllegalArgumentException.get(id)Optional<V>Resolve a value without exposing ownership internals.
ids() / values()detached snapshotSnapshot the current contents for completion, display or compilation.
ids()is immutable; callers should treat the detachedvalues()collection as read-only.onChanged(listener)Invalidate a derived cache whenever anything is registered or removed.
Registration.close()Remove precisely that registration. Closing it twice is safe.
Keystone removes all entries belonging to a plugin when that plugin disables. This prevents a
registry from calling an object whose class loader has gone away. registerInternal is reserved
for values owned by the plugin that owns the registry; it deliberately skips the addon namespace
check.
Change listeners run synchronously on the thread that registered or removed the value. Keep them small and use them to invalidate a cache; schedule Bukkit world work through the Keystone scheduler.