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)Registration

Add 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 snapshot

Snapshot the current contents for completion, display or compilation. ids() is immutable; callers should treat the detached values() 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.