publicKitList = KitManager.get().getPublicKitList();
-
- for (int i = 0; i < publicKitList.size(); i++) {
- PublicKit kit = publicKitList.get(i);
- if (KitManager.get().hasPublicKit(kit.id)) {
- if (player.hasPermission("perplayerkit.admin")) {
- menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, ChatColor.RESET + kit.name, lang("gui.lore-admin-shift-edit")));
- } else {
- menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, ChatColor.RESET + kit.name));
- }
- addPublicKitButton(menu.getSlot(i + 18), kit.id);
- } else {
- String unassignedName = ChatColor.RESET + kit.name + " " + lang("gui.unassigned-tag");
- if (player.hasPermission("perplayerkit.admin")) {
- menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, unassignedName,
- lang("gui.lore-unassigned-info"), lang("gui.lore-admin-shift-edit")));
- } else {
- menu.getSlot(i + 18).setItem(createItem(kit.icon, 1, unassignedName, lang("gui.lore-unassigned-info")));
- }
- }
-
- if (player.hasPermission("perplayerkit.admin")) {
- addAdminPublicKitButton(menu.getSlot(i + 18), kit.id);
- }
- }
-
- addMainButton(menu.getSlot(BACK_SLOT));
- menu.getSlot(BACK_SLOT).setItem(createItem(Material.OAK_DOOR, 1, lang("gui.back-button")));
- openMenu(player, titledMenu);
- }
-
- public void addClear(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- Menu m = info.getClickedMenu();
- for (int i = 0; i < 41; i++) {
- m.getSlot(i).setItem((org.bukkit.inventory.ItemStack) null);
- }
- }
- });
- }
-
- public void addClear(Slot slot, int start, int end) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- Menu m = info.getClickedMenu();
- for (int i = start; i < end; i++) {
- m.getSlot(i).setItem((org.bukkit.inventory.ItemStack) null);
- }
- }
- });
- }
-
- public void addClearKit(Slot slot, UUID target, int slotNum) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- KitManager.get().deleteKit(target, slotNum);
- Lang.get().send(player, "success.admin-kit-deleted", "slot", String.valueOf(slotNum));
- SoundManager.playSuccess(player);
- kitDeletionFlag.add(player.getUniqueId());
- info.getClickedMenu().close();
- SoundManager.playCloseGui(player);
- }
- });
- }
-
- public void addClearEnderchest(Slot slot, UUID target, int slotNum) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- KitManager.get().deleteEnderchest(target, slotNum);
- Lang.get().send(player, "success.admin-ec-deleted", "slot", String.valueOf(slotNum));
- SoundManager.playSuccess(player);
- kitDeletionFlag.add(player.getUniqueId());
- info.getClickedMenu().close();
- SoundManager.playCloseGui(player);
- }
- });
- }
-
- public void addPublicKitButton(Slot slot, String id) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType() == ClickType.LEFT) {
- KitManager.get().loadPublicKit(player, id);
- info.getClickedMenu().close();
- } else if (info.getClickType() == ClickType.RIGHT) {
- ViewPublicKitMenu(player, id);
- }
- });
- }
-
- public void addAdminPublicKitButton(Slot slot, String id) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- OpenPublicKitEditor(player, id);
- return;
- }
- if (info.getClickType() == ClickType.LEFT) {
- KitManager.get().loadPublicKit(player, id);
- } else if (info.getClickType() == ClickType.RIGHT) {
- ViewPublicKitMenu(player, id);
- }
- });
- }
-
- public void addMainButton(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- OpenMainMenu(player, lastMainMenuPage.getOrDefault(player.getUniqueId(), 0));
- });
- }
-
- public void addMainButton(Slot slot, int page) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- OpenMainMenu(player, page);
- });
- }
-
- private void addPageArrow(Menu menu, int guiSlot, String nameKey, int targetPage, int pages) {
- menu.getSlot(guiSlot).setItem(createItem(Material.ARROW, 1, lang(nameKey),
- lang("gui.lore-page-indicator", "page", String.valueOf(targetPage + 1), "pages", String.valueOf(pages))));
- addMainButton(menu.getSlot(guiSlot), targetPage);
- }
-
- public void addKitRoom(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- OpenKitRoom(player);
- BroadcastManager.get().broadcastPlayerOpenedKitRoom(player);
- });
- }
-
- public void addKitRoom(Slot slot, int page) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- OpenKitRoom(player, page);
- });
- }
-
- public void addPublicKitMenu(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- OpenPublicKitMenu(player);
- });
- }
-
- public void addKitRoomSaveButton(Slot slot, int page) {
- slot.setClickHandler((player, info) -> {
- if (!info.getClickType().isRightClick() || !info.getClickType().isShiftClick()) {
- return;
- }
- if (!player.hasPermission("perplayerkit.editkitroom")) {
- return;
- }
- Inventory top = player.getOpenInventory().getTopInventory();
- ItemStack[] data = new ItemStack[FOOTER_START];
- for (int i = 0; i < FOOTER_START; i++) {
- ItemStack item = top.getItem(i);
- data[i] = item == null ? null : item.clone();
- }
- KitRoomDataManager.get().setKitRoom(page, data);
- KitRoomDataManager.get().saveToDBAsync();
- Lang.get().send(player, "success.kitroom-menu-saved");
- SoundManager.playSuccess(player);
- });
- }
-
- public void addRepairButton(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- BroadcastManager.get().broadcastPlayerRepaired(player);
- PlayerUtil.repairAll(player);
- player.updateInventory();
- SoundManager.playSuccess(player);
- });
- }
-
- public void addClearButton(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isShiftClick()) {
- player.getInventory().clear();
- Lang.get().send(player, "success.inventory-cleared");
- SoundManager.playSuccess(player);
- }
- });
- }
-
- public void addImport(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- Menu m = info.getClickedMenu();
- ItemStack[] inv;
- if (filterItemsOnImport) {
- inv = ItemFilter.get().filterItemStack(player.getInventory().getContents());
- } else {
- inv = player.getInventory().getContents();
- }
- for (int i = 0; i < 41; i++) {
- m.getSlot(i).setItem(inv[i]);
- }
- });
- }
-
- public void addImportEC(Slot slot) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- Menu m = info.getClickedMenu();
- ItemStack[] inv;
- if (filterItemsOnImport) {
- inv = ItemFilter.get().filterItemStack(player.getEnderChest().getContents());
- } else {
- inv = player.getEnderChest().getContents();
- }
- for (int i = 0; i < 27; i++) {
- m.getSlot(i + 9).setItem(inv[i]);
- }
- });
- }
-
- public void addEdit(Slot slot, int i) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isLeftClick() || info.getClickType().isRightClick()) {
- OpenKitMenu(player, i);
- }
- });
- }
-
- public void addEditEC(Slot slot, int i) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType().isLeftClick() || info.getClickType().isRightClick()) {
- OpenECKitKenu(player, i);
- }
- });
- }
-
- public void addLoad(Slot slot, int i) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) {
- KitManager.get().loadKit(player, i);
- info.getClickedMenu().close();
- SoundManager.playCloseGui(player);
- }
- });
+ ConfigurableGuiService.get().openPublicKitMenu(player);
}
- public void addEditLoad(Slot slot, int i) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) {
- KitManager.get().loadKit(player, i);
- info.getClickedMenu().close();
- } else if (info.getClickType() == ClickType.RIGHT || info.getClickType() == ClickType.SHIFT_RIGHT) {
- OpenKitMenu(player, i);
- }
- });
+ public void InspectKit(Player player, UUID target, int slot) {
+ ConfigurableGuiService.get().openInspectKit(player, target, slot);
}
- public void addEditLoadEC(Slot slot, int i) {
- slot.setClickHandler((player, info) -> {
- SoundManager.playClick(player);
- if (info.getClickType() == ClickType.LEFT || info.getClickType() == ClickType.SHIFT_LEFT) {
- KitManager.get().loadEnderchest(player, i);
- info.getClickedMenu().close();
- } else if (info.getClickType() == ClickType.RIGHT || info.getClickType() == ClickType.SHIFT_RIGHT) {
- OpenECKitKenu(player, i);
- }
- });
+ public void InspectEc(Player player, UUID target, int slot) {
+ ConfigurableGuiService.get().openInspectEnderchest(player, target, slot);
}
}
diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java b/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java
index 47ca679..ff6bc2d 100644
--- a/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java
+++ b/src/main/java/dev/noah/perplayerkit/gui/GuiCompat.java
@@ -29,7 +29,7 @@
* compiles against: InventoryView#setTitle (1.20+) and
* ItemMeta#setHideTooltip (1.20.5+). Both degrade gracefully on old servers.
*/
-final class GuiCompat {
+public final class GuiCompat {
private static final Method SET_TITLE = findMethod(InventoryView.class, "setTitle", String.class);
private static final Method SET_HIDE_TOOLTIP = findMethod(ItemMeta.class, "setHideTooltip", boolean.class);
@@ -50,11 +50,11 @@ private static Method findMethod(Class> owner, String name, Class>... params
* When false, menus must reopen so titles stay correct, at the cost of the
* client recentering the mouse cursor.
*/
- static boolean supportsTitleUpdate() {
+ public static boolean supportsTitleUpdate() {
return SET_TITLE != null;
}
- static void updateTitle(Player player, String title) {
+ public static void updateTitle(Player player, String title) {
if (SET_TITLE == null || title == null) {
return;
}
diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java b/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java
deleted file mode 100644
index dca918a..0000000
--- a/src/main/java/dev/noah/perplayerkit/gui/GuiLayoutUtils.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright 2022-2025 Noah Ross
- *
- * This file is part of PerPlayerKit.
- *
- * PerPlayerKit is free software: you can redistribute it and/or modify it under
- * the terms of the GNU Affero General Public License as published by the
- * Free Software Foundation, either version 3 of the License, or (at your
- * option) any later version.
- *
- * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
- * more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with PerPlayerKit. If not, see .
- */
-package dev.noah.perplayerkit.gui;
-
-import org.bukkit.Material;
-import org.ipvp.canvas.Menu;
-import org.ipvp.canvas.slot.ClickOptions;
-
-import static dev.noah.perplayerkit.gui.ItemUtil.createGlassPane;
-import static dev.noah.perplayerkit.gui.ItemUtil.createItem;
-
-public final class GuiLayoutUtils {
- public static final int MENU_SIZE = 54;
- public static final int KIT_CONTENT_END = 41;
- public static final int EC_CONTENT_START = 9;
- public static final int EC_CONTENT_END = 36;
- public static final int FOOTER_START = 45;
- public static final int ARMOR_INDICATOR_START = 45;
- public static final int OFFHAND_INDICATOR_SLOT = 49;
- public static final int IMPORT_SLOT = 51;
- public static final int LOAD_PUBLIC_KIT_SLOT = 52;
- public static final int CLEAR_SLOT = 52;
- public static final int BACK_SLOT = 53;
- public static final int MAIN_PREV_PAGE_SLOT = 45;
- public static final int MAIN_NEXT_PAGE_SLOT = 53;
-
- private GuiLayoutUtils() {
- }
-
- public static void allowModificationRange(Menu menu, int startInclusive, int endExclusive) {
- for (int i = startInclusive; i < endExclusive; i++) {
- menu.getSlot(i).setClickOptions(ClickOptions.ALLOW_ALL);
- }
- }
-
- public static void setGlassPaneRange(Menu menu, int startInclusive, int endExclusive) {
- for (int i = startInclusive; i < endExclusive; i++) {
- menu.getSlot(i).setItem(createGlassPane());
- }
- }
-
- public static void setArmorAndOffhandIndicators(Menu menu) {
- menu.getSlot(ARMOR_INDICATOR_START).setItem(createItem(Material.CHAINMAIL_BOOTS, 1, "BOOTS"));
- menu.getSlot(ARMOR_INDICATOR_START + 1).setItem(createItem(Material.CHAINMAIL_LEGGINGS, 1, "LEGGINGS"));
- menu.getSlot(ARMOR_INDICATOR_START + 2).setItem(createItem(Material.CHAINMAIL_CHESTPLATE, 1, "CHESTPLATE"));
- menu.getSlot(ARMOR_INDICATOR_START + 3).setItem(createItem(Material.CHAINMAIL_HELMET, 1, "HELMET"));
- menu.getSlot(OFFHAND_INDICATOR_SLOT).setItem(createItem(Material.SHIELD, 1, "OFFHAND"));
- }
-}
diff --git a/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java b/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java
deleted file mode 100644
index e56c300..0000000
--- a/src/main/java/dev/noah/perplayerkit/gui/GuiMenuFactory.java
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * Copyright 2022-2025 Noah Ross
- *
- * This file is part of PerPlayerKit.
- *
- * PerPlayerKit is free software: you can redistribute it and/or modify it under
- * the terms of the GNU Affero General Public License as published by the
- * Free Software Foundation, either version 3 of the License, or (at your
- * option) any later version.
- *
- * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
- * more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with PerPlayerKit. If not, see .
- */
-package dev.noah.perplayerkit.gui;
-
-import dev.noah.perplayerkit.util.Lang;
-import dev.noah.perplayerkit.util.StyleManager;
-import net.kyori.adventure.text.minimessage.MiniMessage;
-import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
-import org.bukkit.entity.Player;
-import org.ipvp.canvas.Menu;
-import org.ipvp.canvas.type.ChestMenu;
-
-public final class GuiMenuFactory {
- private GuiMenuFactory() {
- }
-
- /** A menu paired with the title it was built with, since canvas doesn't expose it. */
- public record TitledMenu(Menu menu, String title) {
- }
-
- private static String title(String key) {
- return StyleManager.get().getPrimaryColor()
- + LegacyComponentSerializer.legacySection().serialize(MiniMessage.miniMessage().deserialize(Lang.get().raw(key)));
- }
-
- private static String title(String key, String... pairs) {
- return StyleManager.get().getPrimaryColor()
- + LegacyComponentSerializer.legacySection().serialize(MiniMessage.miniMessage().deserialize(Lang.get().raw(key, pairs)));
- }
-
- private static TitledMenu chestMenu(String title) {
- // Redraw makes canvas swap menus inside the already-open inventory, so
- // the client keeps its cursor position instead of recentering on a
- // reopen. The reused inventory keeps its old title, which GUI fixes up
- // afterwards — only possible when the server has InventoryView#setTitle.
- Menu menu = ChestMenu.builder(6).title(title).redraw(GuiCompat.supportsTitleUpdate()).build();
- return new TitledMenu(menu, title);
- }
-
- public static TitledMenu createPublicKitRoomMenu() {
- return chestMenu(title("gui.public-kit-room-title"));
- }
-
- public static TitledMenu createKitMenu(int slot) {
- return chestMenu(title("gui.kit-editor-title", "slot", String.valueOf(slot)));
- }
-
- public static TitledMenu createPublicKitMenu(String id) {
- return chestMenu(title("gui.public-kit-editor-title", "id", id));
- }
-
- public static TitledMenu createECMenu(int slot) {
- return chestMenu(title("gui.enderchest-editor-title", "slot", String.valueOf(slot)));
- }
-
- public static TitledMenu createInspectMenu(int slot, String playerName) {
- return chestMenu(title("gui.inspect-kit-title", "player", playerName, "slot", String.valueOf(slot)));
- }
-
- public static TitledMenu createInspectEcMenu(int slot, String playerName) {
- return chestMenu(title("gui.inspect-ec-title", "player", playerName, "slot", String.valueOf(slot)));
- }
-
- public static TitledMenu createMainMenu(Player player, int page, int pages) {
- if (pages <= 1) {
- return chestMenu(title("gui.main-menu-title", "player", player.getName()));
- }
- return chestMenu(title("gui.main-menu-title-paged",
- "player", player.getName(),
- "page", String.valueOf(page + 1),
- "pages", String.valueOf(pages)));
- }
-
- public static TitledMenu createKitRoomMenu() {
- return chestMenu(title("gui.kit-room-title"));
- }
-
- public static TitledMenu createViewPublicKitMenu(String id) {
- return chestMenu(title("gui.view-public-kit-title", "id", id));
- }
-}
diff --git a/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java b/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java
new file mode 100644
index 0000000..6e1e7be
--- /dev/null
+++ b/src/main/java/dev/noah/perplayerkit/gui/configurable/ConfigurableGuiService.java
@@ -0,0 +1,1081 @@
+/*
+ * Copyright 2022-2025 Noah Ross
+ *
+ * This file is part of PerPlayerKit.
+ *
+ * PerPlayerKit is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Affero General Public License as published by the
+ * Free Software Foundation, either version 3 of the License, or (at your
+ * option) any later version.
+ *
+ * PerPlayerKit is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with PerPlayerKit. If not, see .
+ */
+package dev.noah.perplayerkit.gui.configurable;
+
+import com.google.common.primitives.Ints;
+import dev.noah.perplayerkit.ItemFilter;
+import dev.noah.perplayerkit.KitManager;
+import dev.noah.perplayerkit.KitRoomDataManager;
+import dev.noah.perplayerkit.PublicKit;
+import dev.noah.perplayerkit.gui.GuiCompat;
+import dev.noah.perplayerkit.gui.ItemUtil;
+import dev.noah.perplayerkit.gui.configurable.EditorSession.EditorType;
+import dev.noah.perplayerkit.util.BroadcastManager;
+import dev.noah.perplayerkit.util.IDUtil;
+import dev.noah.perplayerkit.util.KitSlots;
+import dev.noah.perplayerkit.util.Lang;
+import dev.noah.perplayerkit.util.PlayerUtil;
+import dev.noah.perplayerkit.util.SoundManager;
+import dev.noah.perplayerkit.util.StyleManager;
+import me.clip.placeholderapi.PlaceholderAPI;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.ClickType;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.Plugin;
+import org.ipvp.canvas.Menu;
+import org.ipvp.canvas.slot.ClickOptions;
+import org.ipvp.canvas.slot.Slot;
+import org.ipvp.canvas.type.ChestMenu;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.IntPredicate;
+
+/**
+ * Builds and opens the plugin's menus from the layouts in guis.yml.
+ *
+ * Menu structure (slots, items, text, click actions) is config-driven;
+ * behavior stays in code as named components and action types the config
+ * refers to. Editor saving reuses the session model proven in the old GUI
+ * class: an {@link EditorSession} is registered when an editor opens, flushed
+ * either by InventoryCloseEvent or before opening the next menu — the latter
+ * because canvas redraw navigation reuses the open inventory and never fires
+ * a close event.
+ */
+public class ConfigurableGuiService {
+
+ private static final String MAIN_MENU = "main-menu";
+ private static final String PLAYER_KIT_EDITOR = "player-kit-editor";
+ private static final String PUBLIC_KIT_EDITOR = "public-kit-editor";
+ private static final String ENDERCHEST_EDITOR = "enderchest-editor";
+ private static final String INSPECT_KIT = "inspect-kit";
+ private static final String INSPECT_ENDERCHEST = "inspect-enderchest";
+ private static final String KIT_ROOM = "kit-room";
+ private static final String PUBLIC_KIT_MENU = "public-kit-menu";
+ private static final String PUBLIC_KIT_VIEWER = "public-kit-viewer";
+ private static final String VIEW_ONLY_ENDERCHEST = "view-only-enderchest";
+
+ private static final int KIT_DATA_SIZE = 41;
+ private static final int EC_DATA_SIZE = 27;
+
+ private static ConfigurableGuiService instance;
+
+ private final Plugin plugin;
+ private final GuiConfigManager guiConfig;
+ private final boolean filterItemsOnImport;
+ private final Map editorSessions = new HashMap<>();
+ // Set by delete actions so closing the editor afterwards does not
+ // immediately re-save the deleted kit.
+ private final Set skipNextSave = new HashSet<>();
+ // Last main-menu page each player viewed, so back buttons from submenus
+ // return to it instead of resetting to page 1.
+ private final Map lastMainMenuPage = new HashMap<>();
+ // Data-component slot lists, re-resolved on every editor open otherwise.
+ // The config is parsed once at startup, so entries never go stale.
+ private final Map> componentSlotsCache = new HashMap<>();
+
+ public ConfigurableGuiService(Plugin plugin) {
+ this.plugin = plugin;
+ this.guiConfig = new GuiConfigManager(plugin);
+ this.filterItemsOnImport = plugin.getConfig().getBoolean("anti-exploit.import-filter", false);
+ instance = this;
+ }
+
+ public static ConfigurableGuiService get() {
+ if (instance == null) {
+ throw new IllegalStateException("ConfigurableGuiService has not been initialized");
+ }
+ return instance;
+ }
+
+ // ------------------------------------------------------------------
+ // Entry points
+ // ------------------------------------------------------------------
+
+ public void openMainMenu(Player player) {
+ // Commands always start on page 1; only submenu back buttons resume
+ // the remembered page (see executeOpenGui).
+ openMainMenu(player, 0);
+ }
+
+ public void openMainMenu(Player player, int page) {
+ int pages = KitSlots.pageCount();
+ page = Ints.constrainToRange(page, 0, pages - 1);
+ if (pages > 1) {
+ lastMainMenuPage.put(player.getUniqueId(), page);
+ }
+
+ openGui(MAIN_MENU, player, GuiContext.empty()
+ .with("player", player.getName())
+ .with("page_index", page)
+ .with("pages", pages));
+ }
+
+ public void openPlayerKitEditor(Player player, int slot) {
+ GuiContext context = GuiContext.empty()
+ .with("slot", slot)
+ .with("slot_page", KitSlots.pageOf(slot));
+ if (openGui(PLAYER_KIT_EDITOR, player, context) != null) {
+ registerEditor(player, PLAYER_KIT_EDITOR, "kit-data", EditorType.KIT, slot, null, null, null);
+ }
+ }
+
+ public void openPublicKitEditor(Player player, String publicKitId) {
+ GuiContext context = enrichPublicKitContext(GuiContext.empty().with("id", publicKitId), publicKitId);
+ if (openGui(PUBLIC_KIT_EDITOR, player, context) != null) {
+ registerEditor(player, PUBLIC_KIT_EDITOR, "kit-data", EditorType.PUBLIC_KIT, 0, publicKitId, null, null);
+ }
+ }
+
+ public void openEnderchestEditor(Player player, int slot) {
+ GuiContext context = GuiContext.empty()
+ .with("slot", slot)
+ .with("slot_page", KitSlots.pageOf(slot));
+ if (openGui(ENDERCHEST_EDITOR, player, context) != null) {
+ registerEditor(player, ENDERCHEST_EDITOR, "enderchest-data", EditorType.ENDERCHEST, slot, null, null, null);
+ }
+ }
+
+ public void openInspectKit(Player player, UUID target, int slot) {
+ String targetName = PlayerUtil.getPlayerName(target);
+ GuiContext context = GuiContext.empty()
+ .with("slot", slot)
+ .with("player", targetName)
+ .with("target_uuid", target)
+ .with("target_name", targetName);
+ if (openGui(INSPECT_KIT, player, context) != null) {
+ registerEditor(player, INSPECT_KIT, "kit-data", EditorType.INSPECT_KIT, slot, null, target, targetName);
+ }
+ }
+
+ public void openInspectEnderchest(Player player, UUID target, int slot) {
+ String targetName = PlayerUtil.getPlayerName(target);
+ GuiContext context = GuiContext.empty()
+ .with("slot", slot)
+ .with("player", targetName)
+ .with("target_uuid", target)
+ .with("target_name", targetName);
+ if (openGui(INSPECT_ENDERCHEST, player, context) != null) {
+ registerEditor(player, INSPECT_ENDERCHEST, "enderchest-data", EditorType.INSPECT_ENDERCHEST, slot, null, target, targetName);
+ }
+ }
+
+ public void openKitRoom(Player player) {
+ openKitRoom(player, 0);
+ }
+
+ public void openKitRoom(Player player, int page) {
+ page = Ints.constrainToRange(page, 0, KitRoomDataManager.get().pageCount() - 1);
+ openGui(KIT_ROOM, player, GuiContext.empty().with("page_index", page));
+ }
+
+ public void openPublicKitMenu(Player player) {
+ openGui(PUBLIC_KIT_MENU, player, GuiContext.empty());
+ }
+
+ public void openPublicKitViewer(Player player, String publicKitId) {
+ if (KitManager.get().getPublicKit(publicKitId) == null) {
+ Lang.get().send(player, "error.kit-not-found-display");
+ if (player.hasPermission("perplayerkit.admin")) {
+ Lang.get().send(player, "info.assign-publickit-instruction");
+ }
+ return;
+ }
+ openGui(PUBLIC_KIT_VIEWER, player, enrichPublicKitContext(GuiContext.empty().with("id", publicKitId), publicKitId));
+ }
+
+ public void openViewOnlyEnderchest(Player player) {
+ openGui(VIEW_ONLY_ENDERCHEST, player, GuiContext.empty());
+ }
+
+ // ------------------------------------------------------------------
+ // Editor sessions
+ // ------------------------------------------------------------------
+
+ /** Invoked by KitMenuCloseListener when a menu inventory actually closes. */
+ public void handleInventoryClose(Player player, Inventory inventory) {
+ flush(player, inventory);
+ }
+
+ public void handlePlayerQuit(UUID player) {
+ editorSessions.remove(player);
+ skipNextSave.remove(player);
+ lastMainMenuPage.remove(player);
+ }
+
+ private void registerEditor(Player player, String guiId, String dataComponent, EditorType type,
+ int slot, String publicKitId, UUID target, String targetName) {
+ ConfigurationSection guiSection = guiConfig.getGuiSection(guiId);
+ if (guiSection == null) {
+ return;
+ }
+ int menuSize = rows(guiSection) * 9;
+ List dataSlots = componentSlots(guiId, dataComponent);
+ if (dataSlots.isEmpty()) {
+ plugin.getLogger().warning("GUI '" + guiId + "' has no " + dataComponent + " component; edits will not be saved");
+ return;
+ }
+ skipNextSave.remove(player.getUniqueId());
+ editorSessions.put(player.getUniqueId(),
+ new EditorSession(type, slot, publicKitId, target, targetName, dataSlots, menuSize));
+ }
+
+ private void flush(Player player, Inventory inventory) {
+ EditorSession session = editorSessions.get(player.getUniqueId());
+ if (session == null || !session.matches(inventory)) {
+ return;
+ }
+ editorSessions.remove(player.getUniqueId());
+ saveEditor(player, session, inventory);
+ }
+
+ private void flushOpenEditor(Player player) {
+ flush(player, player.getOpenInventory().getTopInventory());
+ // Opening another menu invalidates any editor session (and pending
+ // skip flag) regardless of whether the open inventory still matched.
+ editorSessions.remove(player.getUniqueId());
+ skipNextSave.remove(player.getUniqueId());
+ }
+
+ private void saveEditor(Player player, EditorSession session, Inventory inventory) {
+ if (skipNextSave.remove(player.getUniqueId())) {
+ return;
+ }
+
+ switch (session.type()) {
+ case KIT -> KitManager.get().savekit(player.getUniqueId(), session.slot(),
+ session.extractContents(inventory, KIT_DATA_SIZE));
+ case PUBLIC_KIT -> {
+ if (session.publicKitId() != null && !session.publicKitId().isEmpty()) {
+ KitManager.get().savePublicKit(player, session.publicKitId(),
+ session.extractContents(inventory, KIT_DATA_SIZE));
+ }
+ }
+ case ENDERCHEST -> KitManager.get().saveEC(player.getUniqueId(), session.slot(),
+ session.extractContents(inventory, EC_DATA_SIZE));
+ case INSPECT_KIT -> {
+ if (!player.hasPermission("perplayerkit.admin") || session.target() == null) {
+ return;
+ }
+ if (KitManager.get().savekit(session.target(), session.slot(),
+ session.extractContents(inventory, KIT_DATA_SIZE), true)) {
+ Lang.get().send(player, "success.admin-kit-updated",
+ "slot", String.valueOf(session.slot()), "player", session.targetName());
+ } else {
+ Lang.get().send(player, "error.failed-to-update-kit", "player", session.targetName());
+ }
+ }
+ case INSPECT_ENDERCHEST -> {
+ if (!player.hasPermission("perplayerkit.admin") || session.target() == null) {
+ return;
+ }
+ if (KitManager.get().saveECSilent(session.target(), session.slot(),
+ session.extractContents(inventory, EC_DATA_SIZE))) {
+ Lang.get().send(player, "success.admin-ec-updated",
+ "slot", String.valueOf(session.slot()), "player", session.targetName());
+ } else {
+ Lang.get().send(player, "error.failed-to-update-ec", "player", session.targetName());
+ }
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Menu construction
+ // ------------------------------------------------------------------
+
+ private Menu openGui(String guiId, Player viewer, GuiContext context) {
+ // Flush before rendering, not just before opening: components read kit
+ // data that a still-open editor may not have persisted yet.
+ flushOpenEditor(viewer);
+
+ ConfigurationSection guiSection = guiConfig.getGuiSection(guiId);
+ if (guiSection == null) {
+ plugin.getLogger().warning("Missing GUI definition: " + guiId);
+ return null;
+ }
+
+ // The 1-based display page always accompanies the 0-based logic page,
+ // so titles and lore can use {page} wherever page_index flows.
+ if (context.has("page_index") && !context.has("page")) {
+ context = context.with("page", context.getInt("page_index", 0) + 1);
+ }
+
+ int rows = rows(guiSection);
+ String title = resolveTitle(guiSection, viewer, context);
+
+ // Redraw lets canvas swap menus inside the already-open inventory so
+ // the client keeps its cursor position; the stale title it leaves
+ // behind is updated in place below. Only safe with setTitle support.
+ Menu menu = ChestMenu.builder(rows)
+ .title(title)
+ .redraw(GuiCompat.supportsTitleUpdate())
+ .build();
+ menu.setCursorDropHandler(Menu.ALLOW_CURSOR_DROPPING);
+
+ for (ConfigurationSection elementSection : orderedElements(guiSection)) {
+ renderElement(menu, viewer, context, elementSection, rows * 9);
+ }
+
+ menu.open(viewer);
+ GuiCompat.updateTitle(viewer, title);
+ if (guiSection.getBoolean("open-sound", false)) {
+ SoundManager.playOpenGui(viewer);
+ }
+ return menu;
+ }
+
+ private int rows(ConfigurationSection guiSection) {
+ return Ints.constrainToRange(guiSection.getInt("rows", 6), 1, 6);
+ }
+
+ private String resolveTitle(ConfigurationSection guiSection, Player viewer, GuiContext context) {
+ String titleKey = "title";
+ Integer pages = context.getInt("pages");
+ if (pages != null && pages > 1 && guiSection.isString("title-paged")) {
+ titleKey = "title-paged";
+ }
+ String resolved = resolveText(guiSection.getString(titleKey, "Menu"), viewer, context);
+ return StyleManager.convertMiniMessage(resolved);
+ }
+
+ private List orderedElements(ConfigurationSection guiSection) {
+ ConfigurationSection elementsSection = guiSection.getConfigurationSection("elements");
+ if (elementsSection == null) {
+ return Collections.emptyList();
+ }
+
+ List sections = new ArrayList<>();
+ for (String key : elementsSection.getKeys(false)) {
+ ConfigurationSection section = elementsSection.getConfigurationSection(key);
+ if (section != null) {
+ sections.add(section);
+ }
+ }
+ // Stable sort: explicit "order" keys guarantee layering (fill under
+ // data under buttons) even for elements merged in from YAML templates,
+ // whose iteration position is parser-defined.
+ sections.sort((left, right) -> Integer.compare(left.getInt("order", 0), right.getInt("order", 0)));
+ return sections;
+ }
+
+ private void renderElement(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) {
+ if (!isVisibleToViewer(elementSection, viewer)) {
+ return;
+ }
+
+ String type = elementSection.getString("type", "static").toLowerCase(Locale.ROOT);
+ switch (type) {
+ case "fill", "static" -> renderStaticElement(menu, viewer, context, elementSection, menuSize);
+ case "component" -> renderComponent(menu, viewer, context, elementSection, menuSize);
+ default -> plugin.getLogger().warning("Unknown GUI element type '" + type + "' in " + elementSection.getCurrentPath());
+ }
+ }
+
+ private void renderStaticElement(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) {
+ ItemStack item = buildItem(elementSection.getConfigurationSection("item"), viewer, context);
+ ConfigurationSection actionsSection = elementSection.getConfigurationSection("actions");
+ boolean editable = editableFor(elementSection, viewer);
+
+ for (int slotIndex : parseSlots(elementSection.get("slots"), menuSize)) {
+ Slot slot = menu.getSlot(slotIndex);
+ if (item != null) {
+ slot.setItem(item.clone());
+ }
+ if (editable) {
+ slot.setClickOptions(ClickOptions.ALLOW_ALL);
+ }
+ bindActions(slot, actionsSection, context);
+ }
+ }
+
+ private void renderComponent(Menu menu, Player viewer, GuiContext context, ConfigurationSection elementSection, int menuSize) {
+ String component = elementSection.getString("component", "").toLowerCase(Locale.ROOT);
+ List slots = parseSlots(elementSection.get("slots"), menuSize);
+
+ switch (component) {
+ case "kit-slot-selector" -> renderKitSlotSelector(menu, viewer, slots, elementSection, context);
+ case "main-menu-pagination" -> renderMainMenuPagination(menu, viewer, elementSection, context, menuSize);
+ case "public-kit-list" -> renderPublicKitList(menu, viewer, slots, elementSection, context);
+ case "kit-data" -> renderItemData(menu, viewer, slots, elementSection, resolveKitData(viewer, context));
+ case "enderchest-data" -> renderItemData(menu, viewer, slots, elementSection, resolveEnderchestData(viewer, context));
+ case "player-enderchest-data" -> renderItemData(menu, viewer, slots, elementSection, viewer.getEnderChest().getContents());
+ case "kit-room-data" -> renderItemData(menu, viewer, slots, elementSection,
+ KitRoomDataManager.get().getKitRoomPage(context.getInt("page_index", 0)));
+ case "kit-room-category-buttons" -> renderKitRoomCategoryButtons(menu, viewer, slots, elementSection, context);
+ default -> plugin.getLogger().warning("Unknown GUI component '" + component + "' in " + elementSection.getCurrentPath());
+ }
+ }
+
+ /**
+ * One button per kit slot on the current main-menu page. Kit slot numbers
+ * are derived from the page in the context, so the same element serves
+ * every page of a raised max-kits limit. "source: enderchest" checks
+ * enderchest existence for the exists/missing variants; default is kits.
+ */
+ private void renderKitSlotSelector(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) {
+ boolean enderchest = "enderchest".equalsIgnoreCase(elementSection.getString("source", "kit"));
+ IntPredicate exists = enderchest
+ ? slotNumber -> KitManager.get().hasEC(viewer.getUniqueId(), slotNumber)
+ : slotNumber -> KitManager.get().hasKit(viewer.getUniqueId(), slotNumber);
+ int page = context.getInt("page_index", 0);
+
+ for (int i = 0; i < slots.size(); i++) {
+ int slotNumber = page * KitSlots.SLOTS_PER_PAGE + i + 1;
+ if (slotNumber > KitSlots.maxKits()) {
+ break;
+ }
+
+ GuiContext slotContext = context.with("slot", slotNumber);
+ String variant = exists.test(slotNumber) ? "exists" : "missing";
+ applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext);
+ }
+ }
+
+ private void renderMainMenuPagination(Menu menu, Player viewer, ConfigurationSection elementSection, GuiContext context, int menuSize) {
+ int page = context.getInt("page_index", 0);
+ int pages = context.getInt("pages", 1);
+
+ if (page > 0) {
+ applyPageArrow(menu, viewer, elementSection, "previous", elementSection.get("previous-slot"), page - 1, pages, menuSize);
+ }
+ if (page < pages - 1) {
+ applyPageArrow(menu, viewer, elementSection, "next", elementSection.get("next-slot"), page + 1, pages, menuSize);
+ }
+ }
+
+ private void applyPageArrow(Menu menu, Player viewer, ConfigurationSection elementSection, String variant,
+ Object slotDeclaration, int targetPage, int pages, int menuSize) {
+ List slots = parseSlots(slotDeclaration, menuSize);
+ if (slots.isEmpty()) {
+ return;
+ }
+ GuiContext arrowContext = GuiContext.empty()
+ .with("page_index", targetPage)
+ .with("page", targetPage + 1)
+ .with("pages", pages);
+ applyVariant(menu.getSlot(slots.get(0)), viewer, elementSection, variant, arrowContext);
+ }
+
+ private void renderPublicKitList(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) {
+ List publicKits = KitManager.get().getPublicKitList();
+ boolean admin = viewer.hasPermission("perplayerkit.admin");
+
+ if (publicKits.size() > slots.size()) {
+ plugin.getLogger().warning("public-kit-list has " + slots.size() + " slots but " + publicKits.size()
+ + " public kits are defined; the rest are not shown");
+ }
+
+ for (int i = 0; i < Math.min(slots.size(), publicKits.size()); i++) {
+ PublicKit publicKit = publicKits.get(i);
+ boolean assigned = KitManager.get().hasPublicKit(publicKit.id);
+ String variant = (admin ? "admin_" : "") + (assigned ? "assigned" : "unassigned");
+
+ GuiContext slotContext = context
+ .with("id", publicKit.id)
+ .with("public_kit_name", publicKit.name)
+ .with("public_kit_icon", publicKit.icon.name());
+
+ applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext);
+ }
+ }
+
+ private void renderItemData(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, ItemStack[] data) {
+ boolean editable = editableFor(elementSection, viewer);
+
+ for (int i = 0; i < slots.size(); i++) {
+ Slot slot = menu.getSlot(slots.get(i));
+ ItemStack item = data != null && i < data.length && data[i] != null ? data[i].clone() : null;
+ slot.setItem(item);
+ if (editable) {
+ slot.setClickOptions(ClickOptions.ALLOW_ALL);
+ }
+ }
+ }
+
+ private void renderKitRoomCategoryButtons(Menu menu, Player viewer, List slots, ConfigurationSection elementSection, GuiContext context) {
+ ConfigurationSection categories = plugin.getConfig().getConfigurationSection("kitroom.items");
+ int categoryCount = categories != null ? categories.getKeys(false).size() : 0;
+ int pages = KitRoomDataManager.get().pageCount();
+ if (categoryCount != slots.size() || categoryCount > pages) {
+ plugin.getLogger().warning("kit-room-category-buttons has " + slots.size() + " slots for "
+ + categoryCount + " kitroom categories (storage holds " + pages + " pages)");
+ }
+
+ int currentPage = context.getInt("page_index", 0);
+ for (int i = 0; i < Math.min(Math.min(slots.size(), categoryCount), pages); i++) {
+ String basePath = "kitroom.items." + (i + 1);
+ GuiContext slotContext = context
+ .with("page_index", i)
+ .with("page", i + 1)
+ .with("kitroom_name", plugin.getConfig().getString(basePath + ".name", "Page " + (i + 1)))
+ .with("kitroom_material", plugin.getConfig().getString(basePath + ".material", "BOOK"));
+
+ String variant = i == currentPage ? "active" : "default";
+ applyVariant(menu.getSlot(slots.get(i)), viewer, elementSection, variant, slotContext);
+ }
+ }
+
+ private void applyVariant(Slot slot, Player viewer, ConfigurationSection elementSection, String variantName, GuiContext context) {
+ List chain = variantChain(elementSection, variantName);
+
+ ConfigurationSection itemSource = firstDefining(chain, "item");
+ ItemStack item = itemSource != null ? buildItem(itemSource.getConfigurationSection("item"), viewer, context) : null;
+ if (item != null) {
+ slot.setItem(item);
+ }
+
+ ConfigurationSection editableSource = firstDefining(chain, "editable");
+ if (editableSource != null && editableFor(editableSource, viewer)) {
+ slot.setClickOptions(ClickOptions.ALLOW_ALL);
+ }
+
+ ConfigurationSection actionsSource = firstDefining(chain, "actions");
+ bindActions(slot, actionsSource != null ? actionsSource.getConfigurationSection("actions") : null, context);
+ }
+
+ /**
+ * Per-key fallback order for a variant: the named variant, then
+ * "variants.default", then the element itself — so a variant that only
+ * overrides its item still inherits shared actions or editability.
+ */
+ private List variantChain(ConfigurationSection elementSection, String variantName) {
+ ConfigurationSection variantsSection = elementSection.getConfigurationSection("variants");
+ if (variantsSection == null) {
+ return List.of(elementSection);
+ }
+
+ List chain = new ArrayList<>(3);
+ ConfigurationSection named = variantsSection.getConfigurationSection(variantName);
+ if (named != null) {
+ chain.add(named);
+ }
+ ConfigurationSection defaultSection = variantsSection.getConfigurationSection("default");
+ if (defaultSection != null && defaultSection != named) {
+ chain.add(defaultSection);
+ }
+ chain.add(elementSection);
+ return chain;
+ }
+
+ private ConfigurationSection firstDefining(List chain, String key) {
+ for (ConfigurationSection section : chain) {
+ if (section.contains(key)) {
+ return section;
+ }
+ }
+ return null;
+ }
+
+ private boolean isVisibleToViewer(ConfigurationSection section, Player viewer) {
+ String permission = section.getString("permission");
+ if (permission != null && !permission.isEmpty() && !viewer.hasPermission(permission)) {
+ return false;
+ }
+ String excludedPermission = section.getString("exclude-permission");
+ return excludedPermission == null || excludedPermission.isEmpty() || !viewer.hasPermission(excludedPermission);
+ }
+
+ /**
+ * "editable" is either a boolean or a permission node that grants editing
+ * to viewers who hold it.
+ */
+ private boolean editableFor(ConfigurationSection section, Player viewer) {
+ if (section.isBoolean("editable")) {
+ return section.getBoolean("editable");
+ }
+ String permission = section.getString("editable");
+ return permission != null && !permission.isEmpty() && viewer.hasPermission(permission);
+ }
+
+ // ------------------------------------------------------------------
+ // Actions
+ // ------------------------------------------------------------------
+
+ private void bindActions(Slot slot, ConfigurationSection actionsSection, GuiContext context) {
+ if (actionsSection == null) {
+ return;
+ }
+
+ slot.setClickHandler((player, info) -> {
+ // Feedback on every click of an interactive slot, even when the
+ // click type matches no action (e.g. left click on a shift-only
+ // button) — a silent button reads as broken.
+ SoundManager.playClick(player);
+ for (Map, ?> action : actionsForClick(actionsSection, info.getClickType())) {
+ executeAction(player, info.getClickedMenu(), context, action);
+ }
+ });
+ }
+
+ /**
+ * Picks the action list for a click, most specific key first: the exact
+ * type ("shift_left"), then "shift", "left"/"right"/"middle", then "any".
+ */
+ private List