The API
holopanels-api has four extension points. Between them they cover everything a view can ask for that HoloPanels cannot know itself.
| Interface | Supplies |
|---|---|
EntryProvider | rows for a list panel |
ContentProvider | lines for a text panel |
ConditionEvaluator | a custom visible-if condition |
ActionHandler | a custom click action |
Getting the API
Published through Bukkit's service manager, so the dependency is explicit and it unregisters cleanly.
<dependency>
<groupId>dev.bwmp</groupId>
<artifactId>holopanels-api</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
HoloPanels api = Bukkit.getServicesManager().load(HoloPanels.class);
# plugin.yml
depend: [HoloPanels]
Feeding a list
An EntryProvider returns entries asynchronously. Each entry has an id, a label, any fields you like and any attributes you like.
NamespacedKey topBalances = new NamespacedKey(this, "top_balances");
api.registerEntryProvider(this, topBalances, request ->
CompletableFuture.supplyAsync(() -> economy.topBalances(100).stream()
.map(row -> PanelEntry.builder(row.id(), Component.text(row.name()))
.field("rank", Component.text("#" + row.rank()))
.field("balance", Component.text(row.formatted()))
.attribute("staff", String.valueOf(row.isStaff()))
.build())
.toList()));
The view then names it:
board:
type: list
source:
provider: yourplugin:top_balances
row: "<gray><entry:rank> <white><entry:label> <dark_gray>· <gray><entry:balance>"
empty: "<gray>Loading…"
Field names are yours. <entry:balance> resolves whatever you attached under balance. label is always the entry's label, and attributes are the plain strings that entry-attribute conditions compare.
Feeding a text panel
ContentProvider is the same idea, returning lines rather than rows.
api.registerContentProvider(this, new NamespacedKey(this, "queue_status"),
request -> CompletableFuture.completedFuture(List.of(
Component.text("Position: " + queue.positionOf(request.player())),
Component.text("Ahead of you: " + queue.ahead(request.player())))));
status:
type: text
content-provider: yourplugin:queue_status
lines:
- "<gray>Queue unavailable."
lines stays as the fallback for when the provider has nothing to say.
A custom condition
api.registerCondition(this, new NamespacedKey(this, "in_region"),
context -> regions.contains(
context.argument("region"), context.player().getLocation()));
visible-if:
custom:
id: yourplugin:in_region
region: spawn
Every key other than id arrives as an argument.
A custom action
api.registerAction(this, new NamespacedKey(this, "join_queue"),
context -> {
queue.add(context.player(), context.argument("queue"));
return CompletableFuture.completedFuture(ActionResult.success());
});
clicks:
left:
- type: custom
id: yourplugin:join_queue
queue: pvp
Returning a failure stops the action chain there, the same as any built-in action, which is what makes a custom action composable with confirm-message and the rest.
Two rules
Ids must be in your own plugin's namespace. new NamespacedKey(this, …) gives you that for free, and it is what keeps two plugins from claiming the same provider name.
Everything you register is dropped when your plugin disables. A reload cannot leave a stale handler pointing at a dead classloader, and you do not have to write the teardown.
Keep the returned Registration when your addon needs to withdraw one extension before disable.
Closing it is idempotent.
Controlling a viewer
The API can also refresh one viewer or every viewer of a board, open a particular view for one
viewer, reset that viewer to the root view, or hide the board for that viewer. open and reset
return false when the board or requested view is unavailable.
A note on Adventure
holopanels-api exposes Adventure's Component on its own surface, and HoloPanels is deliberately the Paper-only build of Keystone so that type is the server's Component rather than a relocated copy.
That matters to you as a provider author: you build components with the Adventure that Paper ships, exactly as you would anywhere else in your plugin. Had Adventure been relocated, every third-party provider would fail at runtime against a class name that looks entirely correct. CI asserts the jar contains no kyori classes for that reason.