Skip to content

Commit 10cfb96

Browse files
authored
Merge pull request #3 from Trilleo/copilot/add-paged-plugin-gui
Feature: Add PagedPluginGUI for multi-page inventory menus
2 parents e849864 + 41dcbf4 commit 10cfb96

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

‎docs/DEVELOPER_GUIDE.md‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,107 @@ class SettingsCommand : PluginCommand(
350350
}
351351
}
352352
```
353+
354+
---
355+
356+
## Paged GUIs
357+
358+
To create a multi-page inventory menu with automatic navigation, extend `PagedPluginGUI` and place the class anywhere
359+
inside the `guis` package or a subpackage. `PagedPluginGUI` is a subclass of `PluginGUI` that handles page state per
360+
player and renders **Previous** / **Next** buttons automatically.
361+
362+
The bottom row of the inventory is reserved for navigation controls. Content slots are every slot except the last row.
363+
For example, a 6-row GUI provides 45 content slots per page (rows 1–5).
364+
365+
### PagedPluginGUI Properties
366+
367+
`PagedPluginGUI` inherits all properties from `PluginGUI`:
368+
369+
| Property | Type | Default | Description |
370+
|:---------|:------------|:-------------|:-----------------------------------------|
371+
| `id` | `String` | *(required)* | Unique identifier used to open the GUI |
372+
| `title` | `Component` | *(required)* | Title displayed at the top of the chest |
373+
| `rows` | `Int` | `6` | Number of rows (2–6, each row = 9 slots) |
374+
375+
### Methods to Override
376+
377+
| Method | Required | Description |
378+
|:-----------------|:---------|:------------------------------------------------------------------|
379+
| `getItems` | Yes | Return the full list of items to paginate for a player |
380+
| `onContentClick` | No | Handle clicks on content slots (clicks are cancelled by default) |
381+
382+
You do **not** need to override `setup`, `onClick`, or `onClose` — `PagedPluginGUI` handles them internally for
383+
pagination. If you need custom close logic, override `onClose` and call `super.onClose(event)` to ensure page state
384+
is cleaned up.
385+
386+
### Navigation Layout
387+
388+
The last row of the inventory contains:
389+
390+
| Slot (in last row) | Item | Description |
391+
|:-------------------|:--------|:---------------------------------------------|
392+
| 0 | Arrow | **Previous Page** — hidden on the first page |
393+
| 4 | Paper | **Page indicator** — displays "Page X/Y" |
394+
| 8 | Arrow | **Next Page** — hidden on the last page |
395+
396+
### Example
397+
398+
```kotlin
399+
package com.example.exampleplugin.guis
400+
401+
import com.example.exampleplugin.registration.PagedPluginGUI
402+
import net.kyori.adventure.text.Component
403+
import org.bukkit.Material
404+
import org.bukkit.entity.Player
405+
import org.bukkit.event.inventory.InventoryClickEvent
406+
import org.bukkit.inventory.ItemStack
407+
408+
class RewardsGUI : PagedPluginGUI(
409+
id = "rewards",
410+
title = Component.text("Rewards"),
411+
rows = 6
412+
) {
413+
override fun getItems(player: Player): List<ItemStack> {
414+
return List(100) { index ->
415+
val item = ItemStack(Material.DIAMOND)
416+
val meta = item.itemMeta
417+
meta.displayName(Component.text("Reward #${index + 1}"))
418+
item.itemMeta = meta
419+
item
420+
}
421+
}
422+
423+
override fun onContentClick(event: InventoryClickEvent, page: Int) {
424+
val player = event.whoClicked as? Player ?: return
425+
player.sendMessage("You clicked slot ${event.slot} on page ${page + 1}!")
426+
}
427+
}
428+
```
429+
430+
### Opening a Paged GUI from a Command
431+
432+
Paged GUIs are opened the same way as regular GUIs, using `GUIManager.open(player, id)`:
433+
434+
```kotlin
435+
package com.example.exampleplugin.commands
436+
437+
import com.example.exampleplugin.registration.GUIManager
438+
import com.example.exampleplugin.registration.PluginCommand
439+
import org.bukkit.command.CommandSender
440+
import org.bukkit.entity.Player
441+
442+
class RewardsCommand : PluginCommand(
443+
name = "rewards",
444+
description = "Browse available rewards",
445+
permission = "exampleplugin.rewards"
446+
) {
447+
override fun execute(sender: CommandSender, args: Array<out String>): Boolean {
448+
if (sender !is Player) {
449+
sender.sendMessage("This command can only be used by players.")
450+
return true
451+
}
452+
GUIManager.open(sender, "rewards")
453+
return true
454+
}
455+
}
456+
```
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.example.exampleplugin.registration
2+
3+
import net.kyori.adventure.text.Component
4+
import net.kyori.adventure.text.format.NamedTextColor
5+
import org.bukkit.Material
6+
import org.bukkit.entity.Player
7+
import org.bukkit.event.inventory.InventoryClickEvent
8+
import org.bukkit.event.inventory.InventoryCloseEvent
9+
import org.bukkit.inventory.Inventory
10+
import org.bukkit.inventory.ItemStack
11+
import java.util.UUID
12+
13+
/**
14+
* A [PluginGUI] subclass that automatically splits content across
15+
* multiple pages with **Previous** and **Next** navigation buttons.
16+
*
17+
* Extend this class and place the subclass anywhere inside the
18+
* `com.example.exampleplugin.guis` package (or any subpackage) to
19+
* have it automatically discovered and registered at startup.
20+
*
21+
* The bottom row of the inventory is reserved for navigation controls.
22+
* Content slots are every slot **except** the last row. For example, a
23+
* 6-row GUI provides 45 content slots per page (rows 1–5).
24+
*
25+
* The class must have either:
26+
* - A no-arg constructor, **or**
27+
* - A constructor that accepts a single `JavaPlugin` parameter (the plugin
28+
* instance will be injected automatically).
29+
*
30+
* Example:
31+
* ```kotlin
32+
* package com.example.exampleplugin.guis
33+
*
34+
* import org.bukkit.Material
35+
* import org.bukkit.inventory.ItemStack
36+
*
37+
* class RewardsGUI : PagedPluginGUI(
38+
* id = "rewards",
39+
* title = Component.text("Rewards"),
40+
* rows = 6
41+
* ) {
42+
* override fun getItems(player: Player): List<ItemStack> {
43+
* return List(100) { index ->
44+
* val item = ItemStack(Material.DIAMOND)
45+
* val meta = item.itemMeta
46+
* meta.displayName(Component.text("Reward #${index + 1}"))
47+
* item.itemMeta = meta
48+
* item
49+
* }
50+
* }
51+
* }
52+
* ```
53+
*/
54+
abstract class PagedPluginGUI(
55+
id: String,
56+
title: Component,
57+
rows: Int = 6
58+
) : PluginGUI(id, title, rows) {
59+
60+
/** Tracks the current page for each player viewing this GUI. */
61+
private val playerPages = mutableMapOf<UUID, Int>()
62+
63+
/**
64+
* Returns all items that should be distributed across pages for the
65+
* given player. The list may be of any size; items are automatically
66+
* split into pages of [contentSlots] each.
67+
*
68+
* @param player the player the GUI is being opened for
69+
* @return the full list of items to paginate
70+
*/
71+
abstract fun getItems(player: Player): List<ItemStack>
72+
73+
/**
74+
* Called when a player clicks a **content slot** (not a navigation
75+
* button). Override to add custom click handling.
76+
*
77+
* Clicks are cancelled by default to prevent item theft.
78+
*
79+
* @param event the inventory click event
80+
* @param page the page the player is currently viewing (zero-based)
81+
*/
82+
open fun onContentClick(event: InventoryClickEvent, page: Int) {}
83+
84+
/** The number of usable content slots per page (all rows except the last). */
85+
private val contentSlots: Int
86+
get() = (rows - 1) * ROW_SIZE
87+
88+
/** The first slot index of the navigation row (the last row). */
89+
private val navRowStart: Int
90+
get() = contentSlots
91+
92+
// ----- PluginGUI overrides ------------------------------------------------
93+
94+
override fun setup(player: Player, inventory: Inventory) {
95+
playerPages[player.uniqueId] = 0
96+
renderPage(player, inventory, 0)
97+
}
98+
99+
override fun onClick(event: InventoryClickEvent) {
100+
event.isCancelled = true
101+
val player = event.whoClicked as? Player ?: return
102+
val page = playerPages[player.uniqueId] ?: return
103+
val slot = event.rawSlot
104+
105+
// Ignore clicks outside the GUI inventory
106+
if (slot < 0 || slot >= rows * ROW_SIZE) return
107+
108+
when (slot) {
109+
navRowStart + PREVIOUS_OFFSET -> {
110+
if (page > 0) openPage(player, event.inventory, page - 1)
111+
}
112+
navRowStart + NEXT_OFFSET -> {
113+
val totalPages = totalPages(getItems(player).size)
114+
if (page < totalPages - 1) openPage(player, event.inventory, page + 1)
115+
}
116+
else -> {
117+
if (slot < contentSlots) onContentClick(event, page)
118+
}
119+
}
120+
}
121+
122+
override fun onClose(event: InventoryCloseEvent) {
123+
val player = event.player as? Player ?: return
124+
playerPages.remove(player.uniqueId)
125+
}
126+
127+
// ----- Internal helpers ---------------------------------------------------
128+
129+
/**
130+
* Calculates the total number of pages based on the given [itemCount].
131+
*/
132+
private fun totalPages(itemCount: Int): Int {
133+
if (itemCount == 0) return 1
134+
return (itemCount + contentSlots - 1) / contentSlots
135+
}
136+
137+
/** Switches the player to the given [page] and re-renders the inventory. */
138+
private fun openPage(player: Player, inventory: Inventory, page: Int) {
139+
playerPages[player.uniqueId] = page
140+
renderPage(player, inventory, page)
141+
}
142+
143+
/** Clears the inventory and populates it with the items for [page]. */
144+
private fun renderPage(player: Player, inventory: Inventory, page: Int) {
145+
inventory.clear()
146+
147+
val items = getItems(player)
148+
val totalPages = totalPages(items.size)
149+
val start = page * contentSlots
150+
val end = minOf(start + contentSlots, items.size)
151+
152+
for (i in start until end) {
153+
inventory.setItem(i - start, items[i])
154+
}
155+
156+
// Navigation row
157+
if (page > 0) {
158+
inventory.setItem(navRowStart + PREVIOUS_OFFSET, createNavItem(
159+
Material.ARROW,
160+
Component.text("Previous Page", NamedTextColor.YELLOW)
161+
))
162+
}
163+
164+
inventory.setItem(navRowStart + PAGE_INDICATOR_OFFSET, createNavItem(
165+
Material.PAPER,
166+
Component.text("Page ${page + 1}/$totalPages", NamedTextColor.WHITE)
167+
))
168+
169+
if (page < totalPages - 1) {
170+
inventory.setItem(navRowStart + NEXT_OFFSET, createNavItem(
171+
Material.ARROW,
172+
Component.text("Next Page", NamedTextColor.YELLOW)
173+
))
174+
}
175+
}
176+
177+
/** Creates a navigation item with the given material and display name. */
178+
private fun createNavItem(material: Material, name: Component): ItemStack {
179+
val item = ItemStack(material)
180+
val meta = item.itemMeta
181+
meta.displayName(name)
182+
item.itemMeta = meta
183+
return item
184+
}
185+
186+
companion object {
187+
private const val ROW_SIZE = 9
188+
private const val PREVIOUS_OFFSET = 0
189+
private const val PAGE_INDICATOR_OFFSET = 4
190+
private const val NEXT_OFFSET = 8
191+
}
192+
}

0 commit comments

Comments
 (0)