Content generated with AI — it may contain mistakes.

Interfaces

Menus

Everything a menu file can say: windows, lists, templates, fillers, conditions, animations, clicks and redrawing.

A menu is a YAML file, compiled once and opened cheaply. Nothing about it is written in Java except the data that fills its lists.

PluginMenus menus = Menus.of(this);
 
menus.load("kits", YamlConfiguration.loadConfiguration(file));   // once
menus.open(player, "kits");                                      // whenever

Three things kept apart

What it isLifetime
UiDefinitionWhat the file says, compiledOne per menu, shared by everyone
UiSessionOne player's open windowUntil they close it
UiEntryOne row of a listUntil the list is replaced

Reading the file is the expensive half and happens once. Opening renders the slots that are shown and nothing else.

Loading and opening

CallWhat it does
load(id, section)Compile a menu, reporting bad parts to the console.
load(id, section, problems)The same, reporting them where you want.
register(definition)Register an already-compiled menu.
definition(id)Get one back.
unload()Forget them all, for a reload.
sounds(UiSounds)What this plugin's menus sound like.
refreshBundledDirectory(class, path)Replace a packaged menu directory with the jar's copy at startup.
open(player, id)Open it. Safe from any thread.
open(player, id, context)Open it with context values.
openNow(player, definition, context)Open and return the session. Player's thread only.
back(player) / close(player)Where they came from, or shut it.
session(player)The open session, if it is one of ours.

open moves itself onto the thread that owns the player, so a caller returning from a database query does not have to. openNow cannot, because it hands back the session it opened.

Context

Context values fill placeholders everywhere the menu draws — the title, every fixed slot, every row:

menus.open(player, "leaderboard", Map.of("kit_name", kit.name()));

Context values are parsed, unlike row values: whoever wrote the menu also wrote them, usually in the same file, so "{success}&lNEW SHIELD" arrives as a green button. A row naming the same key shadows the context and keeps its own rule.

The window

type: SIMPLE     # a chest; the default
size: 54         # only a chest is resizable

SIMPLE, PAGINATION, MULTI_PAGINATION, ITEM_INPUT and STATIC are all chests — whether a menu paginates is decided by whether it has a list, which is the only thing that ever decided it.

Any other container works and brings its own fixed size: BARREL, HOPPER, DROPPER, DISPENSER, ANVIL, ENCHANTING, FURNACE, BREWING, BEACON, CRAFTING, MERCHANT, SMITHING, GRINDSTONE, CARTOGRAPHY, LOOM, STONECUTTER.

A wrong size is not cosmetic

Creating an inventory whose size disagrees with its type throws, so the menu never opens at all. A barrel looks like a chest and is not: it is always twenty-seven slots.

The page in the title

title: '{primary}&lMY SHIELDS {muted}%current_page%/%total_pages%'

%current_page%, %total_pages% and the shorter %page% and %pages% are supplied by the list itself — the section knows how many rows it has, so a context value of the same name never shadows them.

The title follows the reader through the list, which Bukkit cannot do: the new title goes out as a packet the client accepts as a retitle of the window it already has open. It is sent only when the title names a page and the text actually changed.

This needs PacketEvents. Without it the title stays on the page it opened at, and everything else is unaffected.

Lists

A menu can have several paginated lists on one screen, each paging independently.

pagination:
  slots: '10-16,19-25,28-34'
  item_template:
    material: "%kit_icon%"
    name: "{warning}&l%kit_name%"
    actions:
      - "practice:select %kit_id%"
  navigation:
    previous: { slot: 45, material: ARROW }
    next:     { slot: 53, material: ARROW }

Several lists are written as named sections instead:

sections:
  players:
    slots: "1-7,10-16,19-25,28-34"
    player_template: { ... }
    navigation: { previous: { slot: 37 }, next: { slot: 43 } }
  stat_types:
    slots: "46-52"
    not_selected_template: { ... }
    selected_template:     { ... }
    navigation: { previous: { slot: 45 }, next: { slot: 53 } }

A pagination block is one section named main, so a menu with one list never has to know section names exist. An arrow inside a navigation block pages its own section — the actions are implied.

Filling one

session.entries(kits.stream()
        .map(kit -> UiEntry.of(kit)
                .with("kit_name", kit.name())
                .with("kit_icon", kit.icon())
                .template(kit.equals(selected) ? "selected" : "not_selected")
                .build())
        .toList());

A row can carry its own item for lists no template could describe:

session.entries("items", stored.stream()
        .map(stack -> UiEntry.of(stack).item(stack).build())
        .toList());

UiEntry.of(kit) keeps the object itself on the row, so a handler reads back which kit was clicked instead of working it out from the item that was drawn:

actions.registerSync("select", (context, args) -> {
    Kit kit = (Kit) context.require(UiKeys.ENTRY);
    ...
});

Replacing the rows keeps the reader where they were, clamped to what still exists — a leaderboard refreshing under somebody on page three leaves them on page three.

Literal values, and the ones that are not

with() inserts a value as text; withFormatted() parses it. Same rule as everywhere else in the library: the question is whose value it is.

UiEntry.of(player)
        .with("player_name", player.getName())          // a player typed it
        .withFormatted("rank", config.rankDisplay())    // an owner wrote it
        .build();

Several lore lines from one value

A value containing <nl> becomes several lore lines, each keeping whatever the template puts around the placeholder:

lore:
  - "{muted} ┃ {letters}%description%"     # one written line, two drawn
A colour on its own cannot be a value

name: "%name_color%&l%kit_name%" does not work and cannot. Substitution happens on the parsed component tree — that is what lets a template be parsed once and shared by every row — and a bare colour parses to an empty component carrying a colour, which does not reach the text beside it.

Pass the whole coloured phrase, or say which state the row is in and let a named template decide.

Templates by name

Any key ending in template is one, named by what comes before it:

WrittenNamed
item_templatethe default
selected_templateselected
no_permissions_templateno_permissions

They are read by shape rather than from a fixed list, so a plugin invents whatever names it needs. A name the file does not declare draws the ordinary row rather than leaving an empty slot.

Fillers

Three different jobs, not one list:

filler:
  global:                      # everything left over
    material: BLACK_STAINED_GLASS_PANE
    hide_tooltip: true
  pagination:                  # a list's empty slots, when it is short
    material: LIGHT_GRAY_STAINED_GLASS_PANE
    name: "{muted}No kits available"
  custom:                      # named panels, each with its own slots
    header:
      material: GRAY_STAINED_GLASS_PANE
      slots: "0-8"

The pagination filler usually says something — it is what somebody with an empty list sees, and treating it as another background tells them nothing.

Panels are drawn before the background, in file order, so the first to claim a slot keeps it.

A page button with nowhere to go is not drawn at all, and its slot goes back to whatever would otherwise cover it. An arrow that is there and does nothing is the same lie in every menu with a single page.

Conditions

join:
  slot: 10
  material: LIME_DYE
  condition: "%lfc_state% == none"

Operators: == != > < >= <= contains startsWith endsWith, and a bare value read as a boolean.

A slot whose condition fails is not blank — it is not there, so clicking it does nothing. A condition that cannot be read hides the slot, because failing the other way would hand a button to somebody who should not have it.

Clicks

actions:
  - "left: practice:adjust_priority 1"
  - "right: practice:adjust_priority -1"
  - "left,right: practice:open_details"

Kinds: left, right, middle, shift_left, shift_right, drop, control_drop, swap, double, number_key, and any for a line with no prefix.

Every decision is made against the session, never against the item the client says it clicked: the packet carries a slot number, and the server already knows what it drew there. A button is never picked up, and a drag touching any button is refused.

Built-in actions

Registered for every plugin that asks for menus, because turning a page is nobody's feature:

ActionWhat it does
next_page, previous_pageMove the only list, or a named one: next_page players.
backThe menu they came from, at the page they left it.
closeShut the window.
refreshRedraw everything.

A plugin registering its own action by one of these names wins.

Editable slots

size: 54
editable_slots: '0-4,9-44'

Slots listed here stay the player's. Buttons around them make it an editor rather than a drop box:

session.input(slot, null);                          // clear one
session.inputs(fromInventory(player));              // fill from what they carry
Map<Integer, ItemStack> layout = session.inputs();  // read it back to save

Both writers refuse a slot that is not an input slot, and inputs(map) checks every slot before writing any of them — a half-applied layout is one the player cannot tell from the one they asked for.

That is how a kit layout, a shop's stock or an arena loadout is edited: the slots themselves are the record, so armour stays in the armour slots and the hotbar stays the hotbar.

Redrawing

A slot declares what it is derived from:

elo:
  slot: 22
  material: DIAMOND
  name: "{letters}Rating: {highlight}%elo%"
  depends-on:
    - stats
session.invalidate("stats");   // only slots that said they depend on stats
session.invalidateSlot(13);    // one slot
session.refresh();             // everything — rarely the right answer

A menu can also redraw itself:

refresh:
  mode: SMART      # DISABLED | FULL | SMART | ON_CLICK
  interval: 20     # ticks, for the timed modes
  click_delay: 4   # ticks after a click, for ON_CLICK and SMART
ModeWhen
DISABLEDOnly when a plugin asks. The default.
FULLEverything, on the interval.
SMARTOn the interval, but only slots that can differ — and after a click.
ON_CLICKAfter a click, once click_delay has passed.
SMART is the one to reach for

A timer that redraws static decorations is packets for an identical item. SMART only starts a timer if the menu has something that could change, and it dies with the player.

A click redraws everything that can change, not only the slot it landed on: adding a layer moves a counter, a preview and a list, and none of those is the slot that was clicked.

Where a player was

A player who closes a menu and opens it again is put back where they were: the page of every list is restored on its own. Anything else worth keeping — the tab that was open, a filter — is marked by the menu and read back before it opens, because which tab is open decides which rows there are:

session.remember("category");                                  // while it is open
Object tab = menus.remembered(player, "effects").get("category");   // before opening it again

Reading does not consume it, and nothing is put back behind the caller's back: what to do with it is the menu's decision. Check it against the catalogue as it is now — a tab that was renamed between two visits would otherwise draw an empty grid with no way out.

A double-click is refused outright

The click before it already ran the button, so delivering the pair would run it twice — a toggle clicked quickly would turn itself back off. It is refused before it reaches a handler.

Animations

animation: center_out
animation:
  type: rows_alternate
  speed: 3          # ticks between frames

Seventeen shapes: center_out, explosion, corners, cascade, slide_left, slide_top, wave_horizontal, wave_vertical, rows_alternate, columns_alternate, checkerboard, snake, spiral, spiral_out, typewriter, random, none.

Everything is drawn and recorded before the animation starts, so a click on a slot that has not visibly appeared yet still works. Clicking skips the rest of it, because somebody interacting has stopped watching.

random is seeded by the menu's size rather than by the clock, so it looks the same for everyone. A name that is not one of these is reported when the file is read and the menu appears at once — silence would make a misspelling look exactly like a menu that was never animated.

Sounds

sounds:
  open: "BLOCK_BARREL_OPEN|0.6|1.4"
  click: "UI_BUTTON_CLICK|1.0|1.5"
  denied: ""        # silence, which is not the same as absent

Names: open, close, click, denied, failed, back, page. The older open_sounds and click_sounds lists also work, and the block wins where a file has both.

denied and failed play when a button refuses, so a click that did nothing sounds different from one that worked — the single most common "the menu is broken" report.

Lifecycle

Nothing outlives its screen. An action sequence with a delayed step, started by a button, is cancelled when the menu closes. Disabling a plugin closes its windows before releasing its tasks.

Menus are found through the window's holder rather than a map keyed by player, so a player opening a chest on top of a menu is not mistaken for one of ours.

Something missing on this page? Tell us on Discord