rune

docs · authoring

Menus.

rune.gui() is the chest-inventory UI builder. You declare a title and a row count, fill it with items, attach click handlers per slot, and open it on a player. Clicks are auto-cancelled so the inventory acts as a control surface rather than something the player drags items in and out of.

A simple menu

The minimal menu is title + rows + a few slots. Slots are numbered 0 to rows * 9 - 1 in row-major order.

export function openAdminMenu(player: Player): void {
  const gui = rune.gui({
    title: "<gradient:#9b87f5:#5b3df5>Ward Admin</gradient>",
    rows: 3,
  });

  gui.fill(GLASS());                             // decorative background
  gui.slot(11, USERS_ICON(player), (e) => {
    openUsersMenu(e.getWhoClicked() as Player, 0);
  });
  gui.slot(13, GROUPS_ICON(), (e) => {
    openGroupsMenu(e.getWhoClicked() as Player, 0);
  });
  gui.slot(15, TRACKS_ICON(), (e) => {
    openTracksMenu(e.getWhoClicked() as Player, 0);
  });
  gui.slot(22, CLOSE_ICON(), (e) => {
    e.getWhoClicked().closeInventory();
  });

  gui.open(player);
}

The handler signature mirrors InventoryClickEvent — you get the full event, including which item was clicked and which mouse button was used. The event is auto-cancelled before your handler runs, so the player can't accidentally take the icon out.

Building items

The icon constants in the example above are factory functions that return real ItemStacks via rune.item(). The builder takes a Material, then chains name/lore/skull/enchant calls, and ends with .build().

const USERS_ICON = (player: Player) =>
  rune.item(bukkit.Material.PLAYER_HEAD)
    .skullOwner(player)
    .name("<gradient:#ffd166:#f7b500>Users</gradient>")
    .lore([
      "<gray>View and edit any player's",
      "<gray>permissions, groups, and rank.",
    ])
    .build();

const GLASS = () =>
  rune.item(bukkit.Material.GRAY_STAINED_GLASS_PANE)
    .name(" ")
    .build();

Strings passed to .name() and .lore() are MiniMessage by default. The builder handles the legacy conversion that vanilla item display names insist on.

Pagination

Lists longer than a single inventory want pagination. There's no built-in paginator — the pattern is to slice your data into pages and render one at a time. Below is the helper ward uses; it's a stateless function that returns a window into the underlying list.

interface Page<T> {
  items: T[];
  page: number;
  total: number;
  hasPrev: boolean;
  hasNext: boolean;
}

export function paginate<T>(all: T[], page: number, size: number): Page<T> {
  const total = Math.max(1, Math.ceil(all.length / size));
  const clamped = Math.max(0, Math.min(page, total - 1));
  const start = clamped * size;
  return {
    items: all.slice(start, start + size),
    page: clamped,
    total,
    hasPrev: clamped > 0,
    hasNext: clamped < total - 1,
  };
}

Wire prev/next clicks to re-open the menu with page + 1 or page - 1. Because each open is a fresh rune.gui(), the only state to manage is the page number.

Chat prompts

For text input — naming a group, picking a number — chest GUIs don't help. ward uses a chat-prompt pattern: close the inventory, send a question, register a one-shot chat listener, resolve a promise with the typed answer.

export function prompt(player: Player, question: string): Promise<string | null> {
  return new Promise((resolve) => {
    runOnMain(() => {
      player.closeInventory();
      player.sendMessage(rune.mm(question));
    });

    awaitChat(String(player.getUniqueId()), (text) => {
      const trimmed = text.trim();
      if (!trimmed || trimmed.toLowerCase() === "cancel") {
        resolve(null);
        return;
      }
      resolve(trimmed);
    });
  });
}

The internal awaitChat helper is a registry keyed by UUID; an underlying @EventHandler on AsyncChatEvent looks the player up, cancels the event, and fires the callback. Reload clears the registry — if a player was mid-prompt, they need to re-open the menu.

Decorating with fills

gui.fill(item) places item in every slot that doesn't already have one assigned. Call it before your real slots so the background sits behind them. gui.border(item) is a convenience for filling just the outer ring — useful when you want a glass frame around your content.

gui.border(GLASS());                          // frame
gui.slot(13, PRIMARY_ACTION_ICON(), onClick);  // centerpiece
gui.slot(15, SECONDARY_ICON(), onSecondary);
gui.slot(22, CLOSE_ICON(), (e) => e.getWhoClicked().closeInventory());