From 6097f0c4d4b08702d4842a0da2ec43bdd90c5257 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:08:15 -0700 Subject: [PATCH 1/8] bug/TestAttempt attempts hover text --- dependency-reduced-pom.xml | 2 +- src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java | 2 +- src/main/resources/plugin.yml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 9092587..18f7b3b 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -155,7 +155,7 @@ io.papermc.paper paper-api - 1.21.8-R0.1-SNAPSHOT + 1.21.4-R0.1-SNAPSHOT provided diff --git a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java index 2c9756c..cde09a9 100644 --- a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java +++ b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java @@ -96,7 +96,7 @@ public void setHasChanged(boolean hasChanged) { @Override public String getPlainItemText(ChatPaginator paginator, ICommandContext generatorInfo) { - return "Attempt #" + attemptNumber + " - Successful: " + successful; + return attemptNotes; } @Override diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c34f869..5b2dfb2 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,4 +10,6 @@ softdepend: - Vault - dynmap - Essentials + - EssentialsChat + - LuckPerms - WorldEdit \ No newline at end of file From 228a1c9068af23b73049a1aaee9436e687acfe34 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:17:44 -0700 Subject: [PATCH 2/8] feature/signmanager --- .../net/greenfieldmc/core/GreenfieldCore.java | 4 +- .../net/greenfieldmc/core/ModuleConfig.java | 7 + .../core/signmanager/SavedSign.java | 186 ++++++++++++ .../core/signmanager/SavedSignGroup.java | 117 ++++++++ .../core/signmanager/SignFlag.java | 29 ++ .../core/signmanager/SignManagerEntry.java | 75 +++++ .../core/signmanager/SignManagerMessages.java | 33 ++ .../core/signmanager/SignManagerModule.java | 46 +++ .../arguments/SignFlagArgument.java | 39 +++ .../arguments/SignGroupNameArgument.java | 45 +++ .../arguments/SignNameArgument.java | 45 +++ .../services/ISignManagerGUIService.java | 37 +++ .../services/ISignManagerService.java | 99 ++++++ .../services/ISignManagerStorageService.java | 91 ++++++ .../services/SignManagerCommandService.java | 140 +++++++++ .../services/SignManagerGUIService.java | 282 ++++++++++++++++++ .../services/SignManagerServiceImpl.java | 141 +++++++++ .../SignManagerStorageServiceImpl.java | 169 +++++++++++ 18 files changed, 1584 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java diff --git a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java index 64ec303..dbb17a9 100644 --- a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java +++ b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java @@ -9,6 +9,7 @@ import net.greenfieldmc.core.paintingswitch.PaintingSwitchModule; import net.greenfieldmc.core.powershovel.PowerShovelModule; import net.greenfieldmc.core.redblock.RedblockModule; +import net.greenfieldmc.core.signmanager.SignManagerModule; import net.greenfieldmc.core.templates.TemplatesModule; import net.greenfieldmc.core.testresult.TestResultModule; import net.greenfieldmc.core.utilities.UtilitiesModule; @@ -40,7 +41,8 @@ public void onEnable() { new AdvancedBuildModule(this, ModuleConfig::isAdvancedBuildModeEnabled), new RedblockModule(this, ModuleConfig::isRedblockEnabled), new ChatFormatModule(this, ModuleConfig::isChatFormatEnabled), - new TemplatesModule(this, ModuleConfig::isTemplatesEnabled) + new TemplatesModule(this, ModuleConfig::isTemplatesEnabled), + new SignManagerModule(this, ModuleConfig::isSignManagerEnabled) )); MODULES.forEach(Module::enable); diff --git a/src/main/java/net/greenfieldmc/core/ModuleConfig.java b/src/main/java/net/greenfieldmc/core/ModuleConfig.java index 9e95f9c..e95cf50 100644 --- a/src/main/java/net/greenfieldmc/core/ModuleConfig.java +++ b/src/main/java/net/greenfieldmc/core/ModuleConfig.java @@ -19,6 +19,7 @@ public class ModuleConfig extends Configuration { private final boolean utilities; private final boolean authHub; private final boolean templates; + private final boolean signManager; public ModuleConfig(Plugin plugin) { super(plugin, ConfigType.YML, "moduleConfig"); @@ -37,6 +38,7 @@ public ModuleConfig(Plugin plugin) { addEntry("modules.utilities", true); addEntry("modules.authHub", true); addEntry("modules.templates", true); + addEntry("modules.signManager", true); save(); @@ -53,6 +55,7 @@ public ModuleConfig(Plugin plugin) { utilities = getBoolean("modules.utilities"); authHub = getBoolean("modules.authHub"); templates = getBoolean("modules.templates"); + signManager = getBoolean("modules.signManager"); } @@ -108,4 +111,8 @@ public boolean isTemplatesEnabled() { return templates; } + public boolean isSignManagerEnabled() { + return signManager; + } + } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java new file mode 100644 index 0000000..d28db7e --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java @@ -0,0 +1,186 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.block.Sign; +import org.bukkit.block.sign.Side; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class SavedSign { + + private String name; + private final Material signMaterial; + private final List frontLines; // GsonComponentSerializer JSON strings + private final List backLines; // GsonComponentSerializer JSON strings + private @Nullable SignFlag flag; + + public SavedSign(String name, Material signMaterial, List frontLines, List backLines, @Nullable SignFlag flag) { + this.name = name; + this.signMaterial = signMaterial; + this.frontLines = frontLines; + this.backLines = backLines; + this.flag = flag; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Material getSignMaterial() { + return signMaterial; + } + + public List getFrontLines() { + return frontLines; + } + + public List getBackLines() { + return backLines; + } + + public @Nullable SignFlag getFlag() { + return flag; + } + + public void setFlag(@Nullable SignFlag flag) { + this.flag = flag; + } + + /** + * Builds the lore lines representing the sign's front and back text content. + * Used both for individual sign display and for group display signs. + */ + public List buildLoreLines() { + var lore = new ArrayList(); + lore.add(Component.text("── Front ──", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + for (var line : frontLines) { + var component = deserializeLine(line); + var plain = PlainTextComponentSerializer.plainText().serialize(component); + if (!plain.isBlank()) { + lore.add(Component.text(" ", NamedTextColor.WHITE).append(component).decoration(TextDecoration.ITALIC, false)); + } + } + lore.add(Component.text("── Back ──", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + for (var line : backLines) { + var component = deserializeLine(line); + var plain = PlainTextComponentSerializer.plainText().serialize(component); + if (!plain.isBlank()) { + lore.add(Component.text(" ", NamedTextColor.WHITE).append(component).decoration(TextDecoration.ITALIC, false)); + } + } + return lore; + } + + /** + * Converts the saved sign data back into a placeable sign ItemStack with + * the original front/back text restored in the block entity NBT. + */ + public ItemStack toItemStack() { + var item = new ItemStack(signMaterial, 1); + var meta = item.getItemMeta(); + + if (meta instanceof BlockStateMeta blockStateMeta) { + var state = blockStateMeta.getBlockState(); + if (state instanceof Sign sign) { + var frontSide = sign.getSide(Side.FRONT); + for (int i = 0; i < frontLines.size() && i < 4; i++) { + frontSide.line(i, deserializeLine(frontLines.get(i))); + } + var backSide = sign.getSide(Side.BACK); + for (int i = 0; i < backLines.size() && i < 4; i++) { + backSide.line(i, deserializeLine(backLines.get(i))); + } + blockStateMeta.setBlockState(state); + } + } + + // Set display name + meta.displayName(Component.text(name, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); + + // Build lore: sign text lines + flag info + var lore = buildLoreLines(); + if (flag != null) { + lore.add(Component.empty()); + lore.add(Component.text("Flag: ", NamedTextColor.DARK_GRAY).append(Component.text(flag.getDisplayName(), NamedTextColor.AQUA)).decoration(TextDecoration.ITALIC, false)); + } + meta.lore(lore); + item.setItemMeta(meta); + return item; + } + + /** + * Creates a SavedSign from a sign ItemStack held by a player. + */ + public static @Nullable SavedSign fromItemStack(ItemStack item, String name, @Nullable SignFlag flag) { + if (item == null || !isSignMaterial(item.getType())) return null; + + var meta = item.getItemMeta(); + var frontLines = new ArrayList(); + var backLines = new ArrayList(); + + if (meta instanceof BlockStateMeta blockStateMeta) { + var state = blockStateMeta.getBlockState(); + if (state instanceof Sign sign) { + var frontSide = sign.getSide(Side.FRONT); + for (int i = 0; i < 4; i++) { + frontLines.add(serializeLine(frontSide.line(i))); + } + var backSide = sign.getSide(Side.BACK); + for (int i = 0; i < 4; i++) { + backLines.add(serializeLine(backSide.line(i))); + } + } + } + + // If no block state meta, just use empty lines + while (frontLines.size() < 4) frontLines.add(serializeLine(Component.empty())); + while (backLines.size() < 4) backLines.add(serializeLine(Component.empty())); + + return new SavedSign(name, item.getType(), frontLines, backLines, flag); + } + + /** + * Returns the plain text content of all sign lines for search matching. + */ + public String getPlainText() { + var sb = new StringBuilder(); + sb.append(name).append(" "); + for (var line : frontLines) { + sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" "); + } + for (var line : backLines) { + sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" "); + } + if (flag != null) sb.append(flag.getDisplayName()).append(" "); + return sb.toString(); + } + + public static boolean isSignMaterial(Material material) { + return material != null && material.name().endsWith("_SIGN"); + } + + private static String serializeLine(Component component) { + return GsonComponentSerializer.gson().serialize(component); + } + + private static Component deserializeLine(String json) { + try { + return GsonComponentSerializer.gson().deserialize(json); + } catch (Exception e) { + return Component.text(json); + } + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java new file mode 100644 index 0000000..303fb8e --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java @@ -0,0 +1,117 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a saved group of signs. A group appears as a single entry in the GUI + * using the display sign (from the main hand at save time). Clicking the group in the + * GUI gives the player all member signs. + */ +public class SavedSignGroup { + + private final String name; + private final SavedSign displaySign; + private final List memberSigns; + private @Nullable SignFlag flag; + + public SavedSignGroup(String name, SavedSign displaySign, List memberSigns, @Nullable SignFlag flag) { + this.name = name; + this.displaySign = displaySign; + this.memberSigns = new ArrayList<>(memberSigns); + this.flag = flag; + } + + public String getName() { + return name; + } + + public SavedSign getDisplaySign() { + return displaySign; + } + + public List getMemberSigns() { + return memberSigns; + } + + public @Nullable SignFlag getFlag() { + return flag; + } + + public void setFlag(@Nullable SignFlag flag) { + this.flag = flag; + } + + /** + * Creates the display ItemStack for the GUI. + * Uses the display sign's material and text, plus group metadata in the lore. + */ + public ItemStack toDisplayItemStack() { + var item = displaySign.toItemStack(); + var meta = item.getItemMeta(); + + // Override the display name to show the group name + meta.displayName(Component.text(name, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); + + // Rebuild lore: display sign text + group info + var lore = new ArrayList(); + + // Show the display sign's text in lore + lore.addAll(displaySign.buildLoreLines()); + + lore.add(Component.empty()); + lore.add(Component.text("⬐ Group: ", NamedTextColor.DARK_GRAY) + .append(Component.text(name, NamedTextColor.YELLOW)) + .decoration(TextDecoration.ITALIC, false)); + lore.add(Component.text(" Contains " + memberSigns.size() + " sign(s)", NamedTextColor.GRAY) + .decoration(TextDecoration.ITALIC, false)); + + if (flag != null) { + lore.add(Component.text("Flag: ", NamedTextColor.DARK_GRAY) + .append(Component.text(flag.getDisplayName(), NamedTextColor.AQUA)) + .decoration(TextDecoration.ITALIC, false)); + } + + meta.lore(lore); + item.setItemMeta(meta); + return item; + } + + /** + * Returns all member signs as clean ItemStacks (no display name / lore metadata) + * ready to be given to a player. + */ + public List toMemberItemStacks() { + var items = new ArrayList(); + for (var sign : memberSigns) { + var signItem = sign.toItemStack(); + var meta = signItem.getItemMeta(); + meta.displayName(null); + meta.lore(null); + signItem.setItemMeta(meta); + items.add(signItem); + } + return items; + } + + /** + * Returns the combined plain text of the group for search matching. + */ + public String getPlainText() { + var sb = new StringBuilder(); + sb.append(name).append(" "); + sb.append(displaySign.getPlainText()).append(" "); + for (var sign : memberSigns) { + sb.append(sign.getPlainText()).append(" "); + } + if (flag != null) sb.append(flag.getDisplayName()); + return sb.toString(); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java b/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java new file mode 100644 index 0000000..3655e6b --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java @@ -0,0 +1,29 @@ +package net.greenfieldmc.core.signmanager; + +public enum SignFlag { + CONSTRUCTION("Construction"), + ROAD("Road"), + RAIL("Rail"), + TRANSIT("Transit"), + MUNICIPAL("Municipal"); + + private final String displayName; + + SignFlag(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public static SignFlag fromString(String name) { + if (name == null || name.isBlank()) return null; + try { + return SignFlag.valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java new file mode 100644 index 0000000..adb1f77 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java @@ -0,0 +1,75 @@ +package net.greenfieldmc.core.signmanager; + +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Represents a single entry in the Sign Manager GUI. + * Can be either an individual SavedSign or a SavedSignGroup. + * Groups appear as a single item using the display sign, but give all member signs when clicked. + */ +@SuppressWarnings("DataFlowIssue") +public class SignManagerEntry { + + private final @Nullable SavedSign sign; + private final @Nullable SavedSignGroup group; + + private SignManagerEntry(@Nullable SavedSign sign, @Nullable SavedSignGroup group) { + this.sign = sign; + this.group = group; + } + + public static SignManagerEntry ofSign(SavedSign sign) { + return new SignManagerEntry(sign, null); + } + + public static SignManagerEntry ofGroup(SavedSignGroup group) { + return new SignManagerEntry(null, group); + } + + public boolean isGroup() { + return group != null; + } + + public String getName() { + return isGroup() ? group.getName() : sign.getName(); + } + + public @Nullable SignFlag getFlag() { + return isGroup() ? group.getFlag() : sign.getFlag(); + } + + /** + * Returns the display item for the GUI slot. + */ + public ItemStack toDisplayItemStack() { + return isGroup() ? group.toDisplayItemStack() : sign.toItemStack(); + } + + /** + * Returns all sign items to give the player when this entry is clicked. + * For a single sign, returns one clean item. For a group, returns all member items. + */ + public List toGiveItemStacks() { + if (isGroup()) { + return group.toMemberItemStacks(); + } else { + var signItem = sign.toItemStack(); + var meta = signItem.getItemMeta(); + meta.displayName(null); + meta.lore(null); + signItem.setItemMeta(meta); + return List.of(signItem); + } + } + + /** + * Returns the plain text content for search matching. + */ + public String getPlainText() { + return isGroup() ? group.getPlainText() : sign.getPlainText(); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java new file mode 100644 index 0000000..89ea67e --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java @@ -0,0 +1,33 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.function.Function; + +import static net.greenfieldmc.core.ComponentUtils.moduleMessage; + +public class SignManagerMessages { + + public static final TextComponent MODULE = moduleMessage("SignManager"); + + public static final TextComponent ERROR_NO_RESULTS = Component.text("There are no results to display.", NamedTextColor.RED); + public static final String ERROR_NOT_HOLDING_SIGN = "You must be holding a sign item in your main hand."; + public static final String ERROR_SIGN_NOT_FOUND = "No saved sign found with that name."; + public static final String ERROR_GROUP_NOT_FOUND = "No saved sign group found with that name."; + public static final String ERROR_SIGN_ALREADY_EXISTS = "A sign with that name already exists."; + public static final String ERROR_NO_SIGNS_IN_HOTBAR = "No sign items found in your hotbar."; + public static final String ERROR_MUST_BE_PLAYER = "This command can only be used by players."; + + public static final Function SIGN_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function SIGN_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign group \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign group \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_SAVED_COUNT = (count) -> MODULE.append(Component.text("Saved " + count + " sign(s) from your hotbar.", NamedTextColor.GRAY)); + + public static final Component GUI_TITLE = Component.text("Sign Manager", NamedTextColor.DARK_PURPLE); + public static final Function GUI_TITLE_SEARCH = (query) -> Component.text("Sign Manager - Search: ", NamedTextColor.DARK_PURPLE).append(Component.text(query, NamedTextColor.GOLD)); + public static final Function GUI_TITLE_FLAG = (flag) -> Component.text("Sign Manager - Flag: ", NamedTextColor.DARK_PURPLE).append(Component.text(flag, NamedTextColor.AQUA)); +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java new file mode 100644 index 0000000..1e0ee90 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java @@ -0,0 +1,46 @@ +package net.greenfieldmc.core.signmanager; + +import net.greenfieldmc.core.GreenfieldCore; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleConfig; +import net.greenfieldmc.core.shared.services.IVaultService; +import net.greenfieldmc.core.shared.services.VaultServiceImpl; +import net.greenfieldmc.core.signmanager.services.ISignManagerGUIService; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import net.greenfieldmc.core.signmanager.services.ISignManagerStorageService; +import net.greenfieldmc.core.signmanager.services.SignManagerCommandService; +import net.greenfieldmc.core.signmanager.services.SignManagerGUIService; +import net.greenfieldmc.core.signmanager.services.SignManagerServiceImpl; +import net.greenfieldmc.core.signmanager.services.SignManagerStorageServiceImpl; + +import java.util.function.Predicate; + +public class SignManagerModule extends Module { + + private IVaultService vaultService; + private ISignManagerStorageService storageService; + private ISignManagerService signManagerService; + private ISignManagerGUIService guiService; + + public SignManagerModule(GreenfieldCore plugin, Predicate canEnable) { + super(plugin, canEnable); + } + + @Override + protected void tryEnable() throws Exception { + vaultService = enableIntegration(new VaultServiceImpl(plugin, this), true); + storageService = enableIntegration(new SignManagerStorageServiceImpl(plugin, this), true); + signManagerService = enableIntegration(new SignManagerServiceImpl(plugin, this, storageService), true); + guiService = enableIntegration(new SignManagerGUIService(plugin, this, signManagerService), true); + enableIntegration(new SignManagerCommandService(plugin, this, signManagerService, guiService), true); + } + + @Override + protected void tryDisable() throws Exception { + disableIntegration(guiService); + disableIntegration(signManagerService); + disableIntegration(storageService); + disableIntegration(vaultService); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java new file mode 100644 index 0000000..f79a292 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java @@ -0,0 +1,39 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.SignFlag; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; + +public class SignFlagArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType INVALID_FLAG = new DynamicCommandExceptionType(o -> () -> "Invalid sign flag '" + o.toString() + "'. Valid flags: construction, road, rail, transit, municipal"); + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return Arrays.asList(SignFlag.values()); + } + + @Override + public String convertToNative(SignFlag flag) { + return flag.name().toLowerCase(); + } + + @Override + public SignFlag convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var flag = SignFlag.fromString(nativeType); + if (flag == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw INVALID_FLAG.createWithContext(reader, nativeType); + } + return flag; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java new file mode 100644 index 0000000..c494413 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java @@ -0,0 +1,45 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignGroupNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType GROUP_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign group '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignGroupNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getGroupNames(); + } + + @Override + public String convertToNative(String groupName) { + return groupName.toLowerCase(); + } + + @Override + public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var groups = signManagerService.getGroupNames(); + var match = groups.stream().filter(g -> g.equalsIgnoreCase(nativeType)).findFirst().orElse(null); + if (match == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw GROUP_NOT_FOUND.createWithContext(reader, nativeType); + } + return match; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java new file mode 100644 index 0000000..b74bca7 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java @@ -0,0 +1,45 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType SIGN_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getAllSigns(); + } + + @Override + public String convertToNative(SavedSign sign) { + return sign.getName().toLowerCase(); + } + + @Override + public SavedSign convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var sign = signManagerService.getSign(nativeType); + if (sign == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw SIGN_NOT_FOUND.createWithContext(reader, nativeType); + } + return sign; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java new file mode 100644 index 0000000..f83dbdf --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java @@ -0,0 +1,37 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.signmanager.SignFlag; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +public interface ISignManagerGUIService extends IModuleService { + + /** + * Open the sign manager GUI for a player showing all signs. + * @param player The player to open the GUI for. + */ + void openGUI(Player player); + + /** + * Open the sign manager GUI filtered by a search query. + * @param player The player to open the GUI for. + * @param query The search query. + */ + void openGUIWithSearch(Player player, String query); + + /** + * Open the sign manager GUI filtered by a flag. + * @param player The player to open the GUI for. + * @param flag The flag to filter by. + */ + void openGUIWithFlag(Player player, SignFlag flag); + + /** + * Check if a player has the GUI open. + * @param player The player. + * @return true if the player has the GUI open. + */ + boolean hasGUIOpen(Player player); +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java new file mode 100644 index 0000000..7d42835 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java @@ -0,0 +1,99 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import net.greenfieldmc.core.signmanager.SignFlag; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public interface ISignManagerService extends IModuleService { + + /** + * Save a sign from the player's main hand. + * @param player The player holding the sign. + * @param name The name to save the sign under. + * @param flag An optional flag to categorize the sign. + * @return The saved sign, or null if the player is not holding a sign. + */ + @Nullable SavedSign saveSignFromHand(Player player, String name, @Nullable SignFlag flag); + + /** + * Save all sign items from the player's hotbar as a group. + * The main hand item is used as the display sign in the GUI. + * @param player The player whose hotbar to scan. + * @param groupName The group name. + * @param flag An optional flag to categorize the signs. + * @return The saved group, or null if no signs found in hotbar or main hand is not a sign. + */ + @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName, @Nullable SignFlag flag); + + /** + * Delete a saved sign by name. + * @param name The name of the sign. + * @return true if the sign was found and deleted. + */ + boolean deleteSign(String name); + + /** + * Delete a saved sign group by name. + * @param groupName The group name. + * @return true if the group was found and deleted. + */ + boolean deleteGroup(String groupName); + + /** + * Get a saved sign by name. + * @param name The name of the sign. + * @return The saved sign, or null if not found. + */ + @Nullable SavedSign getSign(String name); + + /** + * Check if a name is already taken by a sign or group. + * @param name The name to check. + * @return true if the name is in use. + */ + boolean nameExists(String name); + + /** + * Get all GUI entries (individual signs + groups as single entries). + * @return A list of all entries. + */ + List getAllEntries(); + + /** + * Fuzzy search entries by name and content. + * @param query The search query. + * @return A list of matching entries. + */ + List searchEntries(String query); + + /** + * Filter entries by flag. + * @param flag The flag to filter by. + * @return A list of entries with the given flag. + */ + List filterByFlag(SignFlag flag); + + /** + * Get all distinct group names. + * @return A list of group names. + */ + List getGroupNames(); + + /** + * Get all individual saved signs (not groups). + * @return A list of all individual saved signs. + */ + List getAllSigns(); + + /** + * Get all sign names (individual signs only, not groups). + * @return A list of sign names. + */ + List getSignNames(); +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java new file mode 100644 index 0000000..02f81cb --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java @@ -0,0 +1,91 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.function.Predicate; + +public interface ISignManagerStorageService extends IModuleService { + + /** + * Get a saved sign by name. + * @param name The name of the sign. + * @return The saved sign, or null if not found. + */ + @Nullable SavedSign getSign(String name); + + /** + * Get all saved signs matching a filter. + * @param filter The filter predicate. + * @return A list of matching saved signs. + */ + List getSigns(Predicate filter); + + /** + * Get all saved signs. + * @return A list of all saved signs. + */ + default List getSigns() { + return getSigns(sign -> true); + } + + /** + * Save a sign to the database. + * @param sign The sign to save. + */ + void saveSign(SavedSign sign); + + /** + * Delete a sign by name. + * @param name The name of the sign to delete. + */ + void deleteSign(String name); + + /** + * Get a saved sign group by name. + * @param name The name of the group. + * @return The saved sign group, or null if not found. + */ + @Nullable SavedSignGroup getGroup(String name); + + /** + * Get all saved sign groups matching a filter. + * @param filter The filter predicate. + * @return A list of matching saved sign groups. + */ + List getGroups(Predicate filter); + + /** + * Get all saved sign groups. + * @return A list of all saved sign groups. + */ + default List getGroups() { + return getGroups(group -> true); + } + + /** + * Save a sign group to the database. + * @param group The sign group to save. + */ + void saveGroup(SavedSignGroup group); + + /** + * Delete a sign group by name. + * @param name The name of the group to delete. + */ + void deleteGroup(String name); + + /** + * Get all distinct group names. + * @return A list of unique group names. + */ + List getGroupNames(); + + /** + * Persist all changes to disk. + */ + void saveDatabase(); +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java new file mode 100644 index 0000000..86e3fff --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java @@ -0,0 +1,140 @@ +package net.greenfieldmc.core.signmanager.services; + +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.builder.CommandBuilder; +import com.njdaeger.pdk.command.brigadier.builder.PdkArgumentTypes; +import com.njdaeger.pdk.command.exception.PDKCommandException; +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SignFlag; +import net.greenfieldmc.core.signmanager.SignManagerMessages; +import net.greenfieldmc.core.signmanager.arguments.SignFlagArgument; +import net.greenfieldmc.core.signmanager.arguments.SignGroupNameArgument; +import net.greenfieldmc.core.signmanager.arguments.SignNameArgument; +import org.bukkit.plugin.Plugin; + +public class SignManagerCommandService extends ModuleService implements IModuleService { + + private final ISignManagerService signManagerService; + private final ISignManagerGUIService guiService; + + public SignManagerCommandService(Plugin plugin, Module module, ISignManagerService signManagerService, ISignManagerGUIService guiService) { + super(plugin, module); + this.signManagerService = signManagerService; + this.guiService = guiService; + } + + // === User commands === + + private void openBrowser(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + guiService.openGUI(player); + } + + private void searchSigns(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var query = ctx.getTyped("query", String.class); + guiService.openGUIWithSearch(player, query); + } + + private void filterByFlag(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var flag = ctx.getTyped("flag", SignFlag.class); + guiService.openGUIWithFlag(player, flag); + } + + // === Staff commands === + + private void saveSign(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var name = ctx.getTyped("name", String.class); + var flag = ctx.getTyped("flag", SignFlag.class, null); + + if (signManagerService.nameExists(name)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var sign = signManagerService.saveSignFromHand(player, name, flag); + if (sign == null) { + ctx.error(SignManagerMessages.ERROR_NOT_HOLDING_SIGN); + return; + } + ctx.send(SignManagerMessages.SIGN_SAVED.apply(name)); + } + + private void deleteSign(ICommandContext ctx) throws PDKCommandException { + var savedSign = ctx.getTyped("signName", SavedSign.class); + signManagerService.deleteSign(savedSign.getName()); + ctx.send(SignManagerMessages.SIGN_DELETED.apply(savedSign.getName())); + } + + private void saveGroup(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var groupName = ctx.getTyped("name", String.class); + var flag = ctx.getTyped("flag", SignFlag.class, null); + + if (signManagerService.nameExists(groupName)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var group = signManagerService.saveGroupFromHotbar(player, groupName, flag); + if (group == null) { + ctx.error(SignManagerMessages.ERROR_NO_SIGNS_IN_HOTBAR); + return; + } + ctx.send(SignManagerMessages.GROUP_SAVED.apply(groupName)); + ctx.send(SignManagerMessages.GROUP_SAVED_COUNT.apply(group.getMemberSigns().size())); + } + + private void deleteGroup(ICommandContext ctx) throws PDKCommandException { + var groupName = ctx.getTyped("groupName", String.class); + boolean deleted = signManagerService.deleteGroup(groupName); + if (!deleted) { + ctx.error(SignManagerMessages.ERROR_GROUP_NOT_FOUND); + return; + } + ctx.send(SignManagerMessages.GROUP_DELETED.apply(groupName)); + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + CommandBuilder.of("sm", "signmanager") + .description("Sign Manager - Save, search, and browse signs") + .permission("greenfieldcore.signmanager.use") + .defaultExecutor(this::openBrowser) + .canExecute() + // User commands + .then("search") + .then("query", PdkArgumentTypes.greedyString()).executes(this::searchSigns) + .end() + .then("flag") + .then("flag", new SignFlagArgument()).executes(this::filterByFlag) + .end() + // Staff commands + .then("save").permission("greenfieldcore.signmanager.manage") + .then("name", PdkArgumentTypes.string()).canExecute(this::saveSign) + .then("flag", new SignFlagArgument()).executes(this::saveSign) + .end() + .end() + .then("delete").permission("greenfieldcore.signmanager.manage") + .then("signName", new SignNameArgument(signManagerService)).executes(this::deleteSign) + .end() + .then("savegroup").permission("greenfieldcore.signmanager.manage") + .then("name", PdkArgumentTypes.string()).canExecute(this::saveGroup) + .then("flag", new SignFlagArgument()).executes(this::saveGroup) + .end() + .end() + .then("deletegroup").permission("greenfieldcore.signmanager.manage") + .then("groupName", new SignGroupNameArgument(signManagerService)).executes(this::deleteGroup) + .end() + .register(plugin); + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java new file mode 100644 index 0000000..ec9365e --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java @@ -0,0 +1,282 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SignFlag; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import net.greenfieldmc.core.signmanager.SignManagerMessages; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class SignManagerGUIService extends ModuleService implements ISignManagerGUIService, Listener { + + private static final int GUI_SIZE = 54; // double chest + private static final int ITEMS_PER_PAGE = 45; // slots 0-44 for sign items + // Bottom row navigation slots + private static final int SLOT_PREV_PAGE = 45; + private static final int SLOT_FILTER_CONSTRUCTION = 46; + private static final int SLOT_FILTER_ROAD = 47; + private static final int SLOT_FILTER_RAIL = 48; + private static final int SLOT_FILTER_TRANSIT = 49; + private static final int SLOT_FILTER_MUNICIPAL = 50; + private static final int SLOT_CLEAR_FILTER = 51; + private static final int SLOT_PAGE_INFO = 52; + private static final int SLOT_NEXT_PAGE = 53; + + private final ISignManagerService signManagerService; + private final Map sessions = new HashMap<>(); + + public SignManagerGUIService(Plugin plugin, Module module, ISignManagerService signManagerService) { + super(plugin, module); + this.signManagerService = signManagerService; + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + for (var entry : sessions.entrySet()) { + var player = Bukkit.getPlayer(entry.getKey()); + if (player != null) player.closeInventory(); + } + sessions.clear(); + } + + @Override + public void openGUI(Player player) { + var entries = signManagerService.getAllEntries(); + var session = new GUISession(entries, null, null); + sessions.put(player.getUniqueId(), session); + renderPage(player, session); + } + + @Override + public void openGUIWithSearch(Player player, String query) { + var entries = signManagerService.searchEntries(query); + var session = new GUISession(entries, query, null); + sessions.put(player.getUniqueId(), session); + renderPage(player, session); + } + + @Override + public void openGUIWithFlag(Player player, SignFlag flag) { + var entries = signManagerService.filterByFlag(flag); + var session = new GUISession(entries, null, flag); + sessions.put(player.getUniqueId(), session); + renderPage(player, session); + } + + @Override + public boolean hasGUIOpen(Player player) { + return sessions.containsKey(player.getUniqueId()); + } + + private void renderPage(Player player, GUISession session) { + Component title; + if (session.searchQuery != null) { + title = SignManagerMessages.GUI_TITLE_SEARCH.apply(session.searchQuery); + } else if (session.activeFlag != null) { + title = SignManagerMessages.GUI_TITLE_FLAG.apply(session.activeFlag.getDisplayName()); + } else { + title = SignManagerMessages.GUI_TITLE; + } + + var inventory = Bukkit.createInventory(null, GUI_SIZE, title); + var entries = session.entries; + int startIndex = session.page * ITEMS_PER_PAGE; + int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, entries.size()); + + // Fill entry display items + for (int i = startIndex; i < endIndex; i++) { + inventory.setItem(i - startIndex, entries.get(i).toDisplayItemStack()); + } + + // Fill bottom row with glass panes + var filler = createGuiItem(Material.GRAY_STAINED_GLASS_PANE, Component.text(" ")); + for (int i = ITEMS_PER_PAGE; i < GUI_SIZE; i++) { + inventory.setItem(i, filler); + } + + // Previous page button + if (session.page > 0) { + inventory.setItem(SLOT_PREV_PAGE, createGuiItem(Material.ARROW, Component.text("← Previous Page", NamedTextColor.GREEN))); + } + + // Next page button + int totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) ITEMS_PER_PAGE)); + if (session.page < totalPages - 1) { + inventory.setItem(SLOT_NEXT_PAGE, createGuiItem(Material.ARROW, Component.text("Next Page →", NamedTextColor.GREEN))); + } + + // Page info + inventory.setItem(SLOT_PAGE_INFO, createGuiItem(Material.PAPER, + Component.text("Page " + (session.page + 1) + "/" + totalPages, NamedTextColor.YELLOW) + .decoration(TextDecoration.ITALIC, false))); + + // Flag filter buttons + setFlagFilterButton(inventory, SLOT_FILTER_CONSTRUCTION, SignFlag.CONSTRUCTION, session.activeFlag); + setFlagFilterButton(inventory, SLOT_FILTER_ROAD, SignFlag.ROAD, session.activeFlag); + setFlagFilterButton(inventory, SLOT_FILTER_RAIL, SignFlag.RAIL, session.activeFlag); + setFlagFilterButton(inventory, SLOT_FILTER_TRANSIT, SignFlag.TRANSIT, session.activeFlag); + setFlagFilterButton(inventory, SLOT_FILTER_MUNICIPAL, SignFlag.MUNICIPAL, session.activeFlag); + + // Clear filter button + inventory.setItem(SLOT_CLEAR_FILTER, createGuiItem(Material.BARRIER, + Component.text("Clear Filters", NamedTextColor.RED).decoration(TextDecoration.ITALIC, false))); + + session.inventory = inventory; + player.openInventory(inventory); + } + + private void setFlagFilterButton(Inventory inventory, int slot, SignFlag flag, @Nullable SignFlag activeFlag) { + boolean isActive = flag == activeFlag; + var material = isActive ? Material.LIME_STAINED_GLASS_PANE : Material.LIGHT_GRAY_STAINED_GLASS_PANE; + var color = isActive ? NamedTextColor.GREEN : NamedTextColor.GRAY; + inventory.setItem(slot, createGuiItem(material, + Component.text(flag.getDisplayName(), color).decoration(TextDecoration.ITALIC, false))); + } + + private ItemStack createGuiItem(Material material, Component name) { + var item = new ItemStack(material, 1); + var meta = item.getItemMeta(); + meta.displayName(name); + item.setItemMeta(meta); + return item; + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) return; + var session = sessions.get(player.getUniqueId()); + if (session == null || session.inventory == null) return; + if (!event.getInventory().equals(session.inventory)) return; + + event.setCancelled(true); + + int slot = event.getRawSlot(); + if (slot < 0 || slot >= GUI_SIZE) return; + + // Click on an entry item (slots 0-44) + if (slot < ITEMS_PER_PAGE) { + int index = session.page * ITEMS_PER_PAGE + slot; + if (index < session.entries.size()) { + var entry = session.entries.get(index); + var items = entry.toGiveItemStacks(); + for (var item : items) { + player.getInventory().addItem(item); + } + if (entry.isGroup()) { + player.sendMessage(SignManagerMessages.MODULE.append( + Component.text("Gave you " + items.size() + " sign(s) from group \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } else { + player.sendMessage(SignManagerMessages.MODULE.append( + Component.text("Gave you sign \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } + } + return; + } + + // Navigation clicks + switch (slot) { + case SLOT_PREV_PAGE -> { + if (session.page > 0) { + session.page--; + renderPage(player, session); + } + } + case SLOT_NEXT_PAGE -> { + int totalPages = Math.max(1, (int) Math.ceil(session.entries.size() / (double) ITEMS_PER_PAGE)); + if (session.page < totalPages - 1) { + session.page++; + renderPage(player, session); + } + } + case SLOT_FILTER_CONSTRUCTION -> applyFlagFilter(player, session, SignFlag.CONSTRUCTION); + case SLOT_FILTER_ROAD -> applyFlagFilter(player, session, SignFlag.ROAD); + case SLOT_FILTER_RAIL -> applyFlagFilter(player, session, SignFlag.RAIL); + case SLOT_FILTER_TRANSIT -> applyFlagFilter(player, session, SignFlag.TRANSIT); + case SLOT_FILTER_MUNICIPAL -> applyFlagFilter(player, session, SignFlag.MUNICIPAL); + case SLOT_CLEAR_FILTER -> { + session.activeFlag = null; + session.searchQuery = null; + session.entries = signManagerService.getAllEntries(); + session.page = 0; + renderPage(player, session); + } + } + } + + private void applyFlagFilter(Player player, GUISession session, SignFlag flag) { + // Toggle: if already active, clear; otherwise set + if (session.activeFlag == flag) { + session.activeFlag = null; + session.entries = session.searchQuery != null + ? signManagerService.searchEntries(session.searchQuery) + : signManagerService.getAllEntries(); + } else { + session.activeFlag = flag; + if (session.searchQuery != null) { + // Combine search + flag + session.entries = signManagerService.searchEntries(session.searchQuery).stream() + .filter(e -> e.getFlag() == flag) + .toList(); + } else { + session.entries = signManagerService.filterByFlag(flag); + } + } + session.page = 0; + renderPage(player, session); + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player) { + sessions.remove(player.getUniqueId()); + } + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + sessions.remove(event.getPlayer().getUniqueId()); + } + + /** + * Tracks the state of a player's open Sign Manager GUI. + */ + private static class GUISession { + List entries; + @Nullable String searchQuery; + @Nullable SignFlag activeFlag; + int page; + @Nullable Inventory inventory; + + GUISession(List entries, @Nullable String searchQuery, @Nullable SignFlag activeFlag) { + this.entries = entries; + this.searchQuery = searchQuery; + this.activeFlag = activeFlag; + this.page = 0; + } + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java new file mode 100644 index 0000000..e604a0c --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java @@ -0,0 +1,141 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import net.greenfieldmc.core.signmanager.SignFlag; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class SignManagerServiceImpl extends ModuleService implements ISignManagerService { + + private final ISignManagerStorageService storageService; + + public SignManagerServiceImpl(Plugin plugin, Module module, ISignManagerStorageService storageService) { + super(plugin, module); + this.storageService = storageService; + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + } + + @Override + public @Nullable SavedSign saveSignFromHand(Player player, String name, @Nullable SignFlag flag) { + var item = player.getInventory().getItemInMainHand(); + if (!SavedSign.isSignMaterial(item.getType())) return null; + + var sign = SavedSign.fromItemStack(item, name, flag); + if (sign == null) return null; + + storageService.saveSign(sign); + storageService.saveDatabase(); + return sign; + } + + @Override + public @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName, @Nullable SignFlag flag) { + // The main hand item becomes the display sign + var mainHandItem = player.getInventory().getItemInMainHand(); + if (!SavedSign.isSignMaterial(mainHandItem.getType())) return null; + + var displaySign = SavedSign.fromItemStack(mainHandItem, groupName + "_display", flag); + if (displaySign == null) return null; + + // Collect all sign items from the hotbar as member signs + var memberSigns = new ArrayList(); + int signCount = 0; + for (int slot = 0; slot < 9; slot++) { + var item = player.getInventory().getItem(slot); + if (item == null || !SavedSign.isSignMaterial(item.getType())) continue; + + signCount++; + var memberSign = SavedSign.fromItemStack(item, groupName + "_" + signCount, flag); + if (memberSign != null) memberSigns.add(memberSign); + } + + if (memberSigns.isEmpty()) return null; + + var group = new SavedSignGroup(groupName, displaySign, memberSigns, flag); + storageService.saveGroup(group); + storageService.saveDatabase(); + return group; + } + + @Override + public boolean deleteSign(String name) { + var sign = storageService.getSign(name); + if (sign == null) return false; + storageService.deleteSign(name); + storageService.saveDatabase(); + return true; + } + + @Override + public boolean deleteGroup(String groupName) { + var group = storageService.getGroup(groupName); + if (group == null) return false; + storageService.deleteGroup(groupName); + storageService.saveDatabase(); + return true; + } + + @Override + public @Nullable SavedSign getSign(String name) { + return storageService.getSign(name); + } + + @Override + public boolean nameExists(String name) { + return storageService.getSign(name) != null || storageService.getGroup(name) != null; + } + + @Override + public List getAllEntries() { + var entries = new ArrayList(); + storageService.getSigns().forEach(sign -> entries.add(SignManagerEntry.ofSign(sign))); + storageService.getGroups().forEach(group -> entries.add(SignManagerEntry.ofGroup(group))); + return entries; + } + + @Override + public List searchEntries(String query) { + if (query == null || query.isBlank()) return getAllEntries(); + var lowerQuery = query.toLowerCase(); + return getAllEntries().stream() + .filter(entry -> entry.getPlainText().toLowerCase().contains(lowerQuery)) + .toList(); + } + + @Override + public List filterByFlag(SignFlag flag) { + return getAllEntries().stream() + .filter(entry -> entry.getFlag() == flag) + .toList(); + } + + @Override + public List getGroupNames() { + return storageService.getGroupNames(); + } + + @Override + public List getAllSigns() { + return storageService.getSigns(); + } + + @Override + public List getSignNames() { + return storageService.getSigns().stream().map(SavedSign::getName).toList(); + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java new file mode 100644 index 0000000..a678754 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java @@ -0,0 +1,169 @@ +package net.greenfieldmc.core.signmanager.services; + +import com.njdaeger.pdk.config.ConfigType; +import com.njdaeger.pdk.config.IConfig; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import net.greenfieldmc.core.signmanager.SignFlag; +import org.bukkit.Material; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public class SignManagerStorageServiceImpl extends ModuleService implements ISignManagerStorageService { + + private final Map signs = new HashMap<>(); + private final Map groups = new HashMap<>(); + private IConfig config; + + public SignManagerStorageServiceImpl(Plugin plugin, Module module) { + super(plugin, module); + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + try { + this.config = ConfigType.YML.createNew(plugin, "signmanager"); + + // Load individual signs + if (config.hasSection("signs")) { + for (var signName : config.getSection("signs").getKeys(false)) { + var sign = loadSign("signs." + signName, signName); + if (sign != null) signs.put(signName.toLowerCase(), sign); + } + } + + // Load groups + if (config.hasSection("groups")) { + for (var groupName : config.getSection("groups").getKeys(false)) { + var section = config.getSection("groups." + groupName); + var flagStr = section.getString("flag"); + var flag = SignFlag.fromString(flagStr); + + // Load display sign + var displaySign = loadSign("groups." + groupName + ".displaySign", groupName + "_display"); + if (displaySign == null) { + plugin.getLogger().warning("SignManager: Group '" + groupName + "' has invalid display sign. Skipping."); + continue; + } + + // Load member signs + var memberSigns = new ArrayList(); + if (section.contains("members")) { + for (var memberKey : section.getSection("members").getKeys(false)) { + var memberSign = loadSign("groups." + groupName + ".members." + memberKey, memberKey); + if (memberSign != null) memberSigns.add(memberSign); + } + } + + groups.put(groupName.toLowerCase(), new SavedSignGroup(groupName, displaySign, memberSigns, flag)); + } + } + } catch (Exception e) { + throw new Exception("Failed to enable SignManagerStorageService", e); + } + } + + private @Nullable SavedSign loadSign(String path, String name) { + var section = config.getSection(path); + if (section == null) return null; + var material = Material.matchMaterial(section.getString("material")); + if (material == null) return null; + var frontLines = section.getStringList("frontLines"); + var backLines = section.getStringList("backLines"); + var flagStr = section.getString("flag"); + var flag = SignFlag.fromString(flagStr); + + while (frontLines.size() < 4) frontLines.add("\"\""); + while (backLines.size() < 4) backLines.add("\"\""); + + return new SavedSign(name, material, frontLines, backLines, flag); + } + + private void persistSign(String path, SavedSign sign) { + config.setEntry(path + ".material", sign.getSignMaterial().name()); + config.setEntry(path + ".frontLines", sign.getFrontLines()); + config.setEntry(path + ".backLines", sign.getBackLines()); + config.setEntry(path + ".flag", sign.getFlag() != null ? sign.getFlag().name() : null); + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + saveDatabase(); + } + + @Override + public @Nullable SavedSign getSign(String name) { + return signs.get(name.toLowerCase()); + } + + @Override + public List getSigns(Predicate filter) { + return new ArrayList<>(signs.values()).stream().filter(filter).toList(); + } + + @Override + public void saveSign(SavedSign sign) { + signs.put(sign.getName().toLowerCase(), sign); + persistSign("signs." + sign.getName().toLowerCase(), sign); + } + + @Override + public void deleteSign(String name) { + var removed = signs.remove(name.toLowerCase()); + if (removed != null) { + config.setEntry("signs." + name.toLowerCase(), null); + } + } + + @Override + public @Nullable SavedSignGroup getGroup(String name) { + return groups.get(name.toLowerCase()); + } + + @Override + public List getGroups(Predicate filter) { + return new ArrayList<>(groups.values()).stream().filter(filter).toList(); + } + + @Override + public void saveGroup(SavedSignGroup group) { + groups.put(group.getName().toLowerCase(), group); + var basePath = "groups." + group.getName().toLowerCase(); + config.setEntry(basePath + ".flag", group.getFlag() != null ? group.getFlag().name() : null); + persistSign(basePath + ".displaySign", group.getDisplaySign()); + // Clear old members and rewrite + config.setEntry(basePath + ".members", null); + var memberSigns = group.getMemberSigns(); + for (int i = 0; i < memberSigns.size(); i++) { + persistSign(basePath + ".members.sign_" + i, memberSigns.get(i)); + } + } + + @Override + public void deleteGroup(String name) { + var removed = groups.remove(name.toLowerCase()); + if (removed != null) { + config.setEntry("groups." + name.toLowerCase(), null); + } + } + + @Override + public List getGroupNames() { + return new ArrayList<>(groups.keySet()); + } + + @Override + public void saveDatabase() { + signs.values().forEach(this::saveSign); + groups.values().forEach(this::saveGroup); + config.save(); + } +} From 37fecba66c871a1f2ba7ecff7eaf07d775b56741 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:31:38 -0700 Subject: [PATCH 3/8] bug/fix premature GUI session exit --- .../core/signmanager/services/SignManagerGUIService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java index ec9365e..f917558 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java @@ -43,6 +43,7 @@ public class SignManagerGUIService extends ModuleService private final ISignManagerService signManagerService; private final Map sessions = new HashMap<>(); + private final java.util.Set rendering = new java.util.HashSet<>(); public SignManagerGUIService(Plugin plugin, Module module, ISignManagerService signManagerService) { super(plugin, module); @@ -146,7 +147,9 @@ private void renderPage(Player player, GUISession session) { Component.text("Clear Filters", NamedTextColor.RED).decoration(TextDecoration.ITALIC, false))); session.inventory = inventory; + rendering.add(player.getUniqueId()); player.openInventory(inventory); + rendering.remove(player.getUniqueId()); } private void setFlagFilterButton(Inventory inventory, int slot, SignFlag flag, @Nullable SignFlag activeFlag) { @@ -252,7 +255,9 @@ private void applyFlagFilter(Player player, GUISession session, SignFlag flag) { @EventHandler public void onInventoryClose(InventoryCloseEvent event) { if (event.getPlayer() instanceof Player player) { - sessions.remove(player.getUniqueId()); + if (!rendering.contains(player.getUniqueId())) { + sessions.remove(player.getUniqueId()); + } } } From df9396d78efdcada34b124b4efe1cb992c899795 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:11:14 -0700 Subject: [PATCH 4/8] bug/mimic center text alignment in lore --- .../core/signmanager/MinecraftFontWidths.java | 145 ++++++++++++++++++ .../core/signmanager/SavedSign.java | 7 +- 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java diff --git a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java new file mode 100644 index 0000000..a45f50d --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java @@ -0,0 +1,145 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Utility for calculating Minecraft text pixel widths and centering text. + * Minecraft uses a variable-width bitmap font where each character has a + * specific pixel width (plus 1px gap between characters). + * Signs center-align text within a 90-pixel-wide area. + */ +public class MinecraftFontWidths { + + // Sign text rendering width in pixels + private static final int SIGN_WIDTH_PIXELS = 90; + + // Space character width (4px glyph + 1px gap = 5px effective) + private static final int SPACE_EFFECTIVE_WIDTH = 4; + + // Default character width if not in the map (6px is the most common width) + private static final int DEFAULT_CHAR_WIDTH = 6; + + // Map of character -> glyph pixel width (not including the 1px inter-char gap) + private static final Map CHAR_WIDTHS = new HashMap<>(); + + static { + // Width 1 + for (char c : "!,.:;|iì".toCharArray()) CHAR_WIDTHS.put(c, 2); + + // Width 2 (glyph 2px) + CHAR_WIDTHS.put('\'', 2); + CHAR_WIDTHS.put('l', 3); + CHAR_WIDTHS.put('`', 2); + + // Width 3 + for (char c : "\"()*Iïî[]{}".toCharArray()) CHAR_WIDTHS.put(c, 4); + CHAR_WIDTHS.put('t', 4); + + // Width 4 + for (char c : " <>fkÎ".toCharArray()) CHAR_WIDTHS.put(c, 4); + + // Width 5 (most common, this is the default) + // a-z (except f,i,k,l,t), A-Z (except I,M,W), 0-9, many symbols + // We handle these via the default + + // Width 6 + for (char c : "@~®©".toCharArray()) CHAR_WIDTHS.put(c, 7); + + // Bold adds 1 pixel to every character - not handled here as lore is typically not bold + + // Specific overrides for common characters + CHAR_WIDTHS.put(' ', 4); + CHAR_WIDTHS.put('i', 2); + CHAR_WIDTHS.put('!', 2); + CHAR_WIDTHS.put(',', 2); + CHAR_WIDTHS.put('.', 2); + CHAR_WIDTHS.put(':', 2); + CHAR_WIDTHS.put(';', 2); + CHAR_WIDTHS.put('|', 2); + CHAR_WIDTHS.put('\'', 2); + CHAR_WIDTHS.put('`', 2); + CHAR_WIDTHS.put('l', 3); + CHAR_WIDTHS.put('"', 4); + CHAR_WIDTHS.put('(', 4); + CHAR_WIDTHS.put(')', 4); + CHAR_WIDTHS.put('*', 4); + CHAR_WIDTHS.put('I', 4); + CHAR_WIDTHS.put('[', 4); + CHAR_WIDTHS.put(']', 4); + CHAR_WIDTHS.put('{', 4); + CHAR_WIDTHS.put('}', 4); + CHAR_WIDTHS.put('t', 4); + CHAR_WIDTHS.put('<', 5); + CHAR_WIDTHS.put('>', 5); + CHAR_WIDTHS.put('f', 5); + CHAR_WIDTHS.put('k', 5); + CHAR_WIDTHS.put('@', 7); + CHAR_WIDTHS.put('~', 7); + } + + /** + * Gets the pixel width of a single character in Minecraft's default font. + * This includes the character glyph width but NOT the 1px inter-character gap. + */ + public static int getCharWidth(char c) { + return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH); + } + + /** + * Calculates the total pixel width of a string of text in Minecraft's default font. + * Each character contributes its glyph width + 1px gap (except the last character). + */ + public static int getTextWidth(String text) { + if (text == null || text.isEmpty()) return 0; + int width = 0; + for (int i = 0; i < text.length(); i++) { + width += getCharWidth(text.charAt(i)); + if (i < text.length() - 1) width += 1; // 1px gap between characters + } + return width; + } + + /** + * Calculates the pixel width of a Component's plain text content. + */ + public static int getComponentWidth(Component component) { + var plainText = PlainTextComponentSerializer.plainText().serialize(component); + return getTextWidth(plainText); + } + + /** + * Creates a center-padded version of the given component for use in lore, + * mimicking how signs render center-aligned text. + * + * @param component The component to center. + * @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS). + * @return A new component with leading spaces to approximate center alignment. + */ + public static Component centerForLore(Component component, int targetWidth) { + int textWidth = getComponentWidth(component); + if (textWidth >= targetWidth) return component; // Already fills or exceeds width + + int totalPadding = targetWidth - textWidth; + int leftPadding = totalPadding / 2; + + // Each space is SPACE_EFFECTIVE_WIDTH pixels wide (+ 1px gap if followed by text) + int spaceCount = leftPadding / (SPACE_EFFECTIVE_WIDTH + 1); + + if (spaceCount <= 0) return component; + + var padding = Component.text(" ".repeat(spaceCount)); + return padding.append(component); + } + + /** + * Centers a component for lore display, using the standard sign width. + */ + public static Component centerForLore(Component component) { + return centerForLore(component, SIGN_WIDTH_PIXELS); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java index d28db7e..f4f74d6 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java @@ -61,6 +61,7 @@ public void setFlag(@Nullable SignFlag flag) { /** * Builds the lore lines representing the sign's front and back text content. + * Sign text is center-aligned, so we pad with spaces to mimic that in lore. * Used both for individual sign display and for group display signs. */ public List buildLoreLines() { @@ -70,7 +71,8 @@ public List buildLoreLines() { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - lore.add(Component.text(" ", NamedTextColor.WHITE).append(component).decoration(TextDecoration.ITALIC, false)); + var centered = MinecraftFontWidths.centerForLore(component); + lore.add(centered.decoration(TextDecoration.ITALIC, false)); } } lore.add(Component.text("── Back ──", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); @@ -78,7 +80,8 @@ public List buildLoreLines() { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - lore.add(Component.text(" ", NamedTextColor.WHITE).append(component).decoration(TextDecoration.ITALIC, false)); + var centered = MinecraftFontWidths.centerForLore(component); + lore.add(centered.decoration(TextDecoration.ITALIC, false)); } } return lore; From 289b70a476c71be029017229c14ca4167a777f22 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:57:42 -0700 Subject: [PATCH 5/8] feature/populate GUI alphabetically --- .../services/SignManagerGUIService.java | 51 +++++++++++++++++-- .../services/SignManagerServiceImpl.java | 1 + 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java index f917558..99c78d6 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java @@ -94,6 +94,11 @@ public boolean hasGUIOpen(Player player) { } private void renderPage(Player player, GUISession session) { + var entries = session.entries; + int startIndex = session.page * ITEMS_PER_PAGE; + int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, entries.size()); + + // Build base title Component title; if (session.searchQuery != null) { title = SignManagerMessages.GUI_TITLE_SEARCH.apply(session.searchQuery); @@ -103,10 +108,15 @@ private void renderPage(Player player, GUISession session) { title = SignManagerMessages.GUI_TITLE; } + // Append page range to title if there are entries on this page + if (startIndex < entries.size()) { + var firstName = entries.get(startIndex).getName().toLowerCase(); + var lastName = entries.get(endIndex - 1).getName().toLowerCase(); + var range = computeSignificantRange(firstName, lastName); + title = title.append(Component.text(" (" + range + ")", NamedTextColor.GRAY)); + } + var inventory = Bukkit.createInventory(null, GUI_SIZE, title); - var entries = session.entries; - int startIndex = session.page * ITEMS_PER_PAGE; - int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, entries.size()); // Fill entry display items for (int i = startIndex; i < endIndex; i++) { @@ -168,6 +178,41 @@ private ItemStack createGuiItem(Material material, Component name) { return item; } + /** + * Computes the significant character range for two entry names. + * Finds the shortest prefix that distinguishes the first name from the last name. + * e.g. "railsign" and "roadsign" -> "ra-ro" + * "aaa" and "aaa" -> "aaa" + * "bridge" and "bus" -> "br-bu" + */ + private String computeSignificantRange(String first, String last) { + if (first.equals(last)) return first; + + // Find the index where the two names first differ + int shared = 0; + int minLen = Math.min(first.length(), last.length()); + while (shared < minLen && first.charAt(shared) == last.charAt(shared)) { + shared++; + } + + // Include up to one character past the divergence point (minimum 1 char total) + int prefixLen = Math.min(shared + 1, minLen); + + // Ensure at least 1 character + prefixLen = Math.max(prefixLen, 1); + + var firstPrefix = first.substring(0, Math.min(prefixLen, first.length())); + var lastPrefix = last.substring(0, Math.min(prefixLen, last.length())); + + if (firstPrefix.equals(lastPrefix)) { + // Edge case: one name is a prefix of the other, extend to distinguish + firstPrefix = first.substring(0, Math.min(prefixLen + 1, first.length())); + lastPrefix = last.substring(0, Math.min(prefixLen + 1, last.length())); + } + + return firstPrefix + "-" + lastPrefix; + } + @EventHandler public void onInventoryClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) return; diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java index e604a0c..4b51e7d 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java @@ -105,6 +105,7 @@ public List getAllEntries() { var entries = new ArrayList(); storageService.getSigns().forEach(sign -> entries.add(SignManagerEntry.ofSign(sign))); storageService.getGroups().forEach(group -> entries.add(SignManagerEntry.ofGroup(group))); + entries.sort(java.util.Comparator.comparing(entry -> entry.getName().toLowerCase())); return entries; } From eb7006b7972c93114921d02d46a2b40b56fa11c7 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:32:19 -0700 Subject: [PATCH 6/8] feature/change inventory paginator to chat paginator --- .../core/signmanager/MinecraftFontWidths.java | 145 -------- .../core/signmanager/SavedSign.java | 80 +++-- .../core/signmanager/SavedSignGroup.java | 69 +--- .../core/signmanager/SignFlag.java | 29 -- .../core/signmanager/SignManagerEntry.java | 126 ++++++- .../core/signmanager/SignManagerMessages.java | 10 +- .../core/signmanager/SignManagerModule.java | 8 +- .../arguments/SignEntryNameArgument.java | 44 +++ .../arguments/SignFlagArgument.java | 39 -- .../paginators/SignManagerPaginator.java | 62 ++++ .../services/ISignManagerGUIService.java | 37 -- .../services/ISignManagerService.java | 26 +- .../services/SignManagerCommandService.java | 68 ++-- .../services/SignManagerGUIService.java | 332 ------------------ .../services/SignManagerServiceImpl.java | 53 ++- .../SignManagerStorageServiceImpl.java | 14 +- 16 files changed, 366 insertions(+), 776 deletions(-) delete mode 100644 src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java delete mode 100644 src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java delete mode 100644 src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java delete mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java delete mode 100644 src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java diff --git a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java deleted file mode 100644 index a45f50d..0000000 --- a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java +++ /dev/null @@ -1,145 +0,0 @@ -package net.greenfieldmc.core.signmanager; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; - -import java.util.HashMap; -import java.util.Map; - -/** - * Utility for calculating Minecraft text pixel widths and centering text. - * Minecraft uses a variable-width bitmap font where each character has a - * specific pixel width (plus 1px gap between characters). - * Signs center-align text within a 90-pixel-wide area. - */ -public class MinecraftFontWidths { - - // Sign text rendering width in pixels - private static final int SIGN_WIDTH_PIXELS = 90; - - // Space character width (4px glyph + 1px gap = 5px effective) - private static final int SPACE_EFFECTIVE_WIDTH = 4; - - // Default character width if not in the map (6px is the most common width) - private static final int DEFAULT_CHAR_WIDTH = 6; - - // Map of character -> glyph pixel width (not including the 1px inter-char gap) - private static final Map CHAR_WIDTHS = new HashMap<>(); - - static { - // Width 1 - for (char c : "!,.:;|iì".toCharArray()) CHAR_WIDTHS.put(c, 2); - - // Width 2 (glyph 2px) - CHAR_WIDTHS.put('\'', 2); - CHAR_WIDTHS.put('l', 3); - CHAR_WIDTHS.put('`', 2); - - // Width 3 - for (char c : "\"()*Iïî[]{}".toCharArray()) CHAR_WIDTHS.put(c, 4); - CHAR_WIDTHS.put('t', 4); - - // Width 4 - for (char c : " <>fkÎ".toCharArray()) CHAR_WIDTHS.put(c, 4); - - // Width 5 (most common, this is the default) - // a-z (except f,i,k,l,t), A-Z (except I,M,W), 0-9, many symbols - // We handle these via the default - - // Width 6 - for (char c : "@~®©".toCharArray()) CHAR_WIDTHS.put(c, 7); - - // Bold adds 1 pixel to every character - not handled here as lore is typically not bold - - // Specific overrides for common characters - CHAR_WIDTHS.put(' ', 4); - CHAR_WIDTHS.put('i', 2); - CHAR_WIDTHS.put('!', 2); - CHAR_WIDTHS.put(',', 2); - CHAR_WIDTHS.put('.', 2); - CHAR_WIDTHS.put(':', 2); - CHAR_WIDTHS.put(';', 2); - CHAR_WIDTHS.put('|', 2); - CHAR_WIDTHS.put('\'', 2); - CHAR_WIDTHS.put('`', 2); - CHAR_WIDTHS.put('l', 3); - CHAR_WIDTHS.put('"', 4); - CHAR_WIDTHS.put('(', 4); - CHAR_WIDTHS.put(')', 4); - CHAR_WIDTHS.put('*', 4); - CHAR_WIDTHS.put('I', 4); - CHAR_WIDTHS.put('[', 4); - CHAR_WIDTHS.put(']', 4); - CHAR_WIDTHS.put('{', 4); - CHAR_WIDTHS.put('}', 4); - CHAR_WIDTHS.put('t', 4); - CHAR_WIDTHS.put('<', 5); - CHAR_WIDTHS.put('>', 5); - CHAR_WIDTHS.put('f', 5); - CHAR_WIDTHS.put('k', 5); - CHAR_WIDTHS.put('@', 7); - CHAR_WIDTHS.put('~', 7); - } - - /** - * Gets the pixel width of a single character in Minecraft's default font. - * This includes the character glyph width but NOT the 1px inter-character gap. - */ - public static int getCharWidth(char c) { - return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH); - } - - /** - * Calculates the total pixel width of a string of text in Minecraft's default font. - * Each character contributes its glyph width + 1px gap (except the last character). - */ - public static int getTextWidth(String text) { - if (text == null || text.isEmpty()) return 0; - int width = 0; - for (int i = 0; i < text.length(); i++) { - width += getCharWidth(text.charAt(i)); - if (i < text.length() - 1) width += 1; // 1px gap between characters - } - return width; - } - - /** - * Calculates the pixel width of a Component's plain text content. - */ - public static int getComponentWidth(Component component) { - var plainText = PlainTextComponentSerializer.plainText().serialize(component); - return getTextWidth(plainText); - } - - /** - * Creates a center-padded version of the given component for use in lore, - * mimicking how signs render center-aligned text. - * - * @param component The component to center. - * @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS). - * @return A new component with leading spaces to approximate center alignment. - */ - public static Component centerForLore(Component component, int targetWidth) { - int textWidth = getComponentWidth(component); - if (textWidth >= targetWidth) return component; // Already fills or exceeds width - - int totalPadding = targetWidth - textWidth; - int leftPadding = totalPadding / 2; - - // Each space is SPACE_EFFECTIVE_WIDTH pixels wide (+ 1px gap if followed by text) - int spaceCount = leftPadding / (SPACE_EFFECTIVE_WIDTH + 1); - - if (spaceCount <= 0) return component; - - var padding = Component.text(" ".repeat(spaceCount)); - return padding.append(component); - } - - /** - * Centers a component for lore display, using the standard sign width. - */ - public static Component centerForLore(Component component) { - return centerForLore(component, SIGN_WIDTH_PIXELS); - } -} - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java index f4f74d6..92a86a5 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java @@ -21,14 +21,12 @@ public class SavedSign { private final Material signMaterial; private final List frontLines; // GsonComponentSerializer JSON strings private final List backLines; // GsonComponentSerializer JSON strings - private @Nullable SignFlag flag; - public SavedSign(String name, Material signMaterial, List frontLines, List backLines, @Nullable SignFlag flag) { + public SavedSign(String name, Material signMaterial, List frontLines, List backLines) { this.name = name; this.signMaterial = signMaterial; this.frontLines = frontLines; this.backLines = backLines; - this.flag = flag; } public String getName() { @@ -51,40 +49,28 @@ public List getBackLines() { return backLines; } - public @Nullable SignFlag getFlag() { - return flag; - } - - public void setFlag(@Nullable SignFlag flag) { - this.flag = flag; - } - /** - * Builds the lore lines representing the sign's front and back text content. - * Sign text is center-aligned, so we pad with spaces to mimic that in lore. - * Used both for individual sign display and for group display signs. + * Builds hover text showing the sign's front and back text content. */ - public List buildLoreLines() { - var lore = new ArrayList(); - lore.add(Component.text("── Front ──", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + public List buildHoverLines() { + var lines = new ArrayList(); + lines.add(Component.text("── Front ──", NamedTextColor.GRAY)); for (var line : frontLines) { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - var centered = MinecraftFontWidths.centerForLore(component); - lore.add(centered.decoration(TextDecoration.ITALIC, false)); + lines.add(Component.text(" ").append(component)); } } - lore.add(Component.text("── Back ──", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); + lines.add(Component.text("── Back ──", NamedTextColor.GRAY)); for (var line : backLines) { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - var centered = MinecraftFontWidths.centerForLore(component); - lore.add(centered.decoration(TextDecoration.ITALIC, false)); + lines.add(Component.text(" ").append(component)); } } - return lore; + return lines; } /** @@ -110,16 +96,6 @@ public ItemStack toItemStack() { } } - // Set display name - meta.displayName(Component.text(name, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); - - // Build lore: sign text lines + flag info - var lore = buildLoreLines(); - if (flag != null) { - lore.add(Component.empty()); - lore.add(Component.text("Flag: ", NamedTextColor.DARK_GRAY).append(Component.text(flag.getDisplayName(), NamedTextColor.AQUA)).decoration(TextDecoration.ITALIC, false)); - } - meta.lore(lore); item.setItemMeta(meta); return item; } @@ -127,7 +103,7 @@ public ItemStack toItemStack() { /** * Creates a SavedSign from a sign ItemStack held by a player. */ - public static @Nullable SavedSign fromItemStack(ItemStack item, String name, @Nullable SignFlag flag) { + public static @Nullable SavedSign fromItemStack(ItemStack item, String name) { if (item == null || !isSignMaterial(item.getType())) return null; var meta = item.getItemMeta(); @@ -152,7 +128,7 @@ public ItemStack toItemStack() { while (frontLines.size() < 4) frontLines.add(serializeLine(Component.empty())); while (backLines.size() < 4) backLines.add(serializeLine(Component.empty())); - return new SavedSign(name, item.getType(), frontLines, backLines, flag); + return new SavedSign(name, item.getType(), frontLines, backLines); } /** @@ -167,10 +143,42 @@ public String getPlainText() { for (var line : backLines) { sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" "); } - if (flag != null) sb.append(flag.getDisplayName()).append(" "); return sb.toString(); } + /** + * Returns a human-readable sign type description based on the material name. + * e.g. "Oak Sign", "Birch Hanging Sign", "Dark Oak Wall Sign" + */ + public String getSignTypeDescription() { + var name = signMaterial.name(); + // Remove _SIGN suffix and convert underscores to spaces, title case + name = name.replace("_SIGN", "").replace("_", " "); + var words = name.toLowerCase().split(" "); + var sb = new StringBuilder(); + for (var word : words) { + if (!word.isEmpty()) { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)).append(" "); + } + } + sb.append("Sign"); + return sb.toString().trim(); + } + + /** + * Returns true if the sign material is a hanging sign type. + */ + public boolean isHangingSign() { + return signMaterial.name().contains("HANGING"); + } + + /** + * Returns true if the sign material is a wall sign type. + */ + public boolean isWallSign() { + return signMaterial.name().contains("WALL"); + } + public static boolean isSignMaterial(Material material) { return material != null && material.name().endsWith("_SIGN"); } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java index 303fb8e..a1c8a82 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java @@ -1,31 +1,25 @@ package net.greenfieldmc.core.signmanager; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.inventory.ItemStack; -import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; /** - * Represents a saved group of signs. A group appears as a single entry in the GUI - * using the display sign (from the main hand at save time). Clicking the group in the - * GUI gives the player all member signs. + * Represents a saved group of signs. A group appears as a single entry in the chat paginator + * using the display sign (from the main hand at save time). Clicking the group entry + * gives the player all member signs. */ public class SavedSignGroup { private final String name; private final SavedSign displaySign; private final List memberSigns; - private @Nullable SignFlag flag; - public SavedSignGroup(String name, SavedSign displaySign, List memberSigns, @Nullable SignFlag flag) { + public SavedSignGroup(String name, SavedSign displaySign, List memberSigns) { this.name = name; this.displaySign = displaySign; this.memberSigns = new ArrayList<>(memberSigns); - this.flag = flag; } public String getName() { @@ -40,62 +34,13 @@ public List getMemberSigns() { return memberSigns; } - public @Nullable SignFlag getFlag() { - return flag; - } - - public void setFlag(@Nullable SignFlag flag) { - this.flag = flag; - } - - /** - * Creates the display ItemStack for the GUI. - * Uses the display sign's material and text, plus group metadata in the lore. - */ - public ItemStack toDisplayItemStack() { - var item = displaySign.toItemStack(); - var meta = item.getItemMeta(); - - // Override the display name to show the group name - meta.displayName(Component.text(name, NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false)); - - // Rebuild lore: display sign text + group info - var lore = new ArrayList(); - - // Show the display sign's text in lore - lore.addAll(displaySign.buildLoreLines()); - - lore.add(Component.empty()); - lore.add(Component.text("⬐ Group: ", NamedTextColor.DARK_GRAY) - .append(Component.text(name, NamedTextColor.YELLOW)) - .decoration(TextDecoration.ITALIC, false)); - lore.add(Component.text(" Contains " + memberSigns.size() + " sign(s)", NamedTextColor.GRAY) - .decoration(TextDecoration.ITALIC, false)); - - if (flag != null) { - lore.add(Component.text("Flag: ", NamedTextColor.DARK_GRAY) - .append(Component.text(flag.getDisplayName(), NamedTextColor.AQUA)) - .decoration(TextDecoration.ITALIC, false)); - } - - meta.lore(lore); - item.setItemMeta(meta); - return item; - } - /** - * Returns all member signs as clean ItemStacks (no display name / lore metadata) - * ready to be given to a player. + * Returns all member signs as clean ItemStacks ready to be given to a player. */ public List toMemberItemStacks() { var items = new ArrayList(); for (var sign : memberSigns) { - var signItem = sign.toItemStack(); - var meta = signItem.getItemMeta(); - meta.displayName(null); - meta.lore(null); - signItem.setItemMeta(meta); - items.add(signItem); + items.add(sign.toItemStack()); } return items; } @@ -110,8 +55,6 @@ public String getPlainText() { for (var sign : memberSigns) { sb.append(sign.getPlainText()).append(" "); } - if (flag != null) sb.append(flag.getDisplayName()); return sb.toString(); } } - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java b/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java deleted file mode 100644 index 3655e6b..0000000 --- a/src/main/java/net/greenfieldmc/core/signmanager/SignFlag.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.greenfieldmc.core.signmanager; - -public enum SignFlag { - CONSTRUCTION("Construction"), - ROAD("Road"), - RAIL("Rail"), - TRANSIT("Transit"), - MUNICIPAL("Municipal"); - - private final String displayName; - - SignFlag(String displayName) { - this.displayName = displayName; - } - - public String getDisplayName() { - return displayName; - } - - public static SignFlag fromString(String name) { - if (name == null || name.isBlank()) return null; - try { - return SignFlag.valueOf(name.toUpperCase()); - } catch (IllegalArgumentException e) { - return null; - } - } -} - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java index adb1f77..22324d8 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java @@ -1,17 +1,33 @@ package net.greenfieldmc.core.signmanager; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.utils.text.pager.ChatPaginator; +import com.njdaeger.pdk.utils.text.pager.PageItem; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.Nullable; import java.util.List; /** - * Represents a single entry in the Sign Manager GUI. + * Represents a single entry in the Sign Manager paginator. * Can be either an individual SavedSign or a SavedSignGroup. - * Groups appear as a single item using the display sign, but give all member signs when clicked. + * Groups appear as a single line using the display sign info, but give all member signs when clicked. */ @SuppressWarnings("DataFlowIssue") -public class SignManagerEntry { +public class SignManagerEntry implements PageItem { + + // Unicode block characters for sign type indicators + // Full block for normal signs, lower half block for hanging signs + private static final String BLOCK_FULL = "█"; // U+2588 Full Block + private static final String BLOCK_LOWER_HALF = "▄"; // U+2584 Lower Half Block + private static final String BLOCK_THREE_QUARTER = "▆"; // U+2586 Lower Three Quarters Block private final @Nullable SavedSign sign; private final @Nullable SavedSignGroup group; @@ -37,39 +53,113 @@ public String getName() { return isGroup() ? group.getName() : sign.getName(); } - public @Nullable SignFlag getFlag() { - return isGroup() ? group.getFlag() : sign.getFlag(); - } - /** - * Returns the display item for the GUI slot. + * Returns the display sign — either the individual sign itself or the group's display sign. */ - public ItemStack toDisplayItemStack() { - return isGroup() ? group.toDisplayItemStack() : sign.toItemStack(); + public SavedSign getDisplaySign() { + return isGroup() ? group.getDisplaySign() : sign; } /** * Returns all sign items to give the player when this entry is clicked. - * For a single sign, returns one clean item. For a group, returns all member items. */ public List toGiveItemStacks() { if (isGroup()) { return group.toMemberItemStacks(); } else { - var signItem = sign.toItemStack(); - var meta = signItem.getItemMeta(); - meta.displayName(null); - meta.lore(null); - signItem.setItemMeta(meta); - return List.of(signItem); + return List.of(sign.toItemStack()); } } + /** + * Returns the number of signs in this entry. + */ + public int getSignCount() { + return isGroup() ? group.getMemberSigns().size() : 1; + } + /** * Returns the plain text content for search matching. */ public String getPlainText() { return isGroup() ? group.getPlainText() : sign.getPlainText(); } -} + @Override + public TextComponent getItemText(ChatPaginator paginator, ICommandContext generatorInfo) { + var displaySign = getDisplaySign(); + var material = displaySign.getSignMaterial(); + + // 1. Sign type indicator: colored unicode block with hover showing material + var blockChar = getBlockCharForSign(displaySign); + var blockColor = getColorForMaterial(material); + var typeIndicator = Component.text(blockChar, blockColor) + .hoverEvent(HoverEvent.showText(Component.text(displaySign.getSignTypeDescription(), NamedTextColor.GRAY))); + + // 2. Count bracket: [N] for groups, [1] for singles + var countText = Component.text(" [" + getSignCount() + "] ", NamedTextColor.GRAY); + + // 3. Name with hover (sign content) and click action (give signs) + var hoverContent = Component.text(); + var hoverLines = displaySign.buildHoverLines(); + for (int i = 0; i < hoverLines.size(); i++) { + if (i > 0) hoverContent.appendNewline(); + hoverContent.append(hoverLines.get(i)); + } + if (isGroup()) { + hoverContent.appendNewline(); + hoverContent.append(Component.text("Group: " + group.getName() + " (" + getSignCount() + " signs)", NamedTextColor.YELLOW)); + } + hoverContent.appendNewline(); + hoverContent.append(Component.text("Click to receive", NamedTextColor.GREEN)); + + var nameComponent = Component.text(getName(), paginator.getHighlightColor()) + .hoverEvent(HoverEvent.showText(hoverContent.build())) + .clickEvent(ClickEvent.runCommand("/sm give " + getName())); + + var line = Component.text(); + line.append(typeIndicator); + line.append(countText); + line.append(nameComponent); + + return line.build(); + } + + @Override + public String getPlainItemText(ChatPaginator paginator, ICommandContext generatorInfo) { + return getName(); + } + + /** + * Returns the appropriate unicode block character for the sign type. + * Full block for regular signs, lower half for hanging signs. + */ + private static String getBlockCharForSign(SavedSign sign) { + if (sign.isHangingSign()) { + return BLOCK_THREE_QUARTER; + } + return BLOCK_FULL; + } + + /** + * Returns a hex color for the sign material type. + * Colors approximate the wood type's appearance. + * These are placeholder values — the user will set exact colors manually. + */ + private static TextColor getColorForMaterial(Material material) { + var name = material.name(); + if (name.startsWith("OAK")) return TextColor.color(0xaa8a61); + if (name.startsWith("SPRUCE")) return TextColor.color(0x705538); + if (name.startsWith("BIRCH")) return TextColor.color(0xc4c4c4); + if (name.startsWith("JUNGLE")) return TextColor.color(0x494949); + if (name.startsWith("ACACIA")) return TextColor.color(0x4b4a4a); + if (name.startsWith("DARK_OAK")) return TextColor.color(0x374d2c); + if (name.startsWith("MANGROVE")) return TextColor.color(0xaa4334); + if (name.startsWith("CHERRY")) return TextColor.color(0x909090); + if (name.startsWith("BAMBOO")) return TextColor.color(0x2c2c2c); + if (name.startsWith("CRIMSON")) return TextColor.color(0xce7533); + if (name.startsWith("WARPED")) return TextColor.color(0x5787a2); + if (name.startsWith("PALE_OAK")) return TextColor.color(0xc7a840); + return TextColor.color(0xC4A054); // default to oak-ish + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java index 89ea67e..f7321d3 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java @@ -12,22 +12,16 @@ public class SignManagerMessages { public static final TextComponent MODULE = moduleMessage("SignManager"); - public static final TextComponent ERROR_NO_RESULTS = Component.text("There are no results to display.", NamedTextColor.RED); public static final String ERROR_NOT_HOLDING_SIGN = "You must be holding a sign item in your main hand."; public static final String ERROR_SIGN_NOT_FOUND = "No saved sign found with that name."; public static final String ERROR_GROUP_NOT_FOUND = "No saved sign group found with that name."; - public static final String ERROR_SIGN_ALREADY_EXISTS = "A sign with that name already exists."; + public static final String ERROR_SIGN_ALREADY_EXISTS = "A sign or group with that name already exists."; public static final String ERROR_NO_SIGNS_IN_HOTBAR = "No sign items found in your hotbar."; - public static final String ERROR_MUST_BE_PLAYER = "This command can only be used by players."; + public static final String ERROR_ENTRY_NOT_FOUND = "No saved sign or group found with that name."; public static final Function SIGN_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign \"" + name + "\".", NamedTextColor.GRAY)); public static final Function SIGN_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign \"" + name + "\".", NamedTextColor.GRAY)); public static final Function GROUP_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign group \"" + name + "\".", NamedTextColor.GRAY)); public static final Function GROUP_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign group \"" + name + "\".", NamedTextColor.GRAY)); public static final Function GROUP_SAVED_COUNT = (count) -> MODULE.append(Component.text("Saved " + count + " sign(s) from your hotbar.", NamedTextColor.GRAY)); - - public static final Component GUI_TITLE = Component.text("Sign Manager", NamedTextColor.DARK_PURPLE); - public static final Function GUI_TITLE_SEARCH = (query) -> Component.text("Sign Manager - Search: ", NamedTextColor.DARK_PURPLE).append(Component.text(query, NamedTextColor.GOLD)); - public static final Function GUI_TITLE_FLAG = (flag) -> Component.text("Sign Manager - Flag: ", NamedTextColor.DARK_PURPLE).append(Component.text(flag, NamedTextColor.AQUA)); } - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java index 1e0ee90..c6f45fe 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java @@ -5,11 +5,9 @@ import net.greenfieldmc.core.ModuleConfig; import net.greenfieldmc.core.shared.services.IVaultService; import net.greenfieldmc.core.shared.services.VaultServiceImpl; -import net.greenfieldmc.core.signmanager.services.ISignManagerGUIService; import net.greenfieldmc.core.signmanager.services.ISignManagerService; import net.greenfieldmc.core.signmanager.services.ISignManagerStorageService; import net.greenfieldmc.core.signmanager.services.SignManagerCommandService; -import net.greenfieldmc.core.signmanager.services.SignManagerGUIService; import net.greenfieldmc.core.signmanager.services.SignManagerServiceImpl; import net.greenfieldmc.core.signmanager.services.SignManagerStorageServiceImpl; @@ -20,7 +18,6 @@ public class SignManagerModule extends Module { private IVaultService vaultService; private ISignManagerStorageService storageService; private ISignManagerService signManagerService; - private ISignManagerGUIService guiService; public SignManagerModule(GreenfieldCore plugin, Predicate canEnable) { super(plugin, canEnable); @@ -31,16 +28,13 @@ protected void tryEnable() throws Exception { vaultService = enableIntegration(new VaultServiceImpl(plugin, this), true); storageService = enableIntegration(new SignManagerStorageServiceImpl(plugin, this), true); signManagerService = enableIntegration(new SignManagerServiceImpl(plugin, this, storageService), true); - guiService = enableIntegration(new SignManagerGUIService(plugin, this, signManagerService), true); - enableIntegration(new SignManagerCommandService(plugin, this, signManagerService, guiService), true); + enableIntegration(new SignManagerCommandService(plugin, this, signManagerService), true); } @Override protected void tryDisable() throws Exception { - disableIntegration(guiService); disableIntegration(signManagerService); disableIntegration(storageService); disableIntegration(vaultService); } } - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java new file mode 100644 index 0000000..511bbfc --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java @@ -0,0 +1,44 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignEntryNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType ENTRY_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign or group '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignEntryNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getAllEntryNames(); + } + + @Override + public String convertToNative(String entryName) { + return entryName.toLowerCase(); + } + + @Override + public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var names = signManagerService.getAllEntryNames(); + var match = names.stream().filter(n -> n.equalsIgnoreCase(nativeType)).findFirst().orElse(null); + if (match == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw ENTRY_NOT_FOUND.createWithContext(reader, nativeType); + } + return match; + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java deleted file mode 100644 index f79a292..0000000 --- a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignFlagArgument.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.greenfieldmc.core.signmanager.arguments; - -import com.mojang.brigadier.StringReader; -import com.mojang.brigadier.exceptions.CommandSyntaxException; -import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; -import com.njdaeger.pdk.command.brigadier.ICommandContext; -import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; -import net.greenfieldmc.core.signmanager.SignFlag; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.Nullable; - -import java.util.Arrays; -import java.util.List; - -public class SignFlagArgument extends AbstractStringTypedArgument { - - private static final DynamicCommandExceptionType INVALID_FLAG = new DynamicCommandExceptionType(o -> () -> "Invalid sign flag '" + o.toString() + "'. Valid flags: construction, road, rail, transit, municipal"); - - @Override - public List listBasicSuggestions(ICommandContext commandContext) { - return Arrays.asList(SignFlag.values()); - } - - @Override - public String convertToNative(SignFlag flag) { - return flag.name().toLowerCase(); - } - - @Override - public SignFlag convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { - var flag = SignFlag.fromString(nativeType); - if (flag == null) { - reader.setCursor(reader.getCursor() - nativeType.length()); - throw INVALID_FLAG.createWithContext(reader, nativeType); - } - return flag; - } -} - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java new file mode 100644 index 0000000..4629d2b --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java @@ -0,0 +1,62 @@ +package net.greenfieldmc.core.signmanager.paginators; + +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.utils.text.pager.ChatPaginatorBuilder; +import com.njdaeger.pdk.utils.text.pager.ComponentPosition; +import com.njdaeger.pdk.utils.text.pager.LineWrappingMode; +import com.njdaeger.pdk.utils.text.pager.components.PageNavigationComponent; +import com.njdaeger.pdk.utils.text.pager.components.ResultCountComponent; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +public class SignManagerPaginator extends ChatPaginatorBuilder { + + public SignManagerPaginator() { + super(); + + setLineWrappingMode(LineWrappingMode.ELLIPSIS); + setResultsPerPage(8); + + // Title + addComponent(Component.text("Sign Manager", NamedTextColor.LIGHT_PURPLE), ComponentPosition.TOP_CENTER); + + // Result count + addComponent(new ResultCountComponent<>(true), ComponentPosition.TOP_LEFT); + + // Search query display in top right + addComponent((ctx, paginator, results, page) -> { + var query = ctx.getTyped("query", String.class, ""); + if (query == null || query.isBlank()) { + return Component.text("No filter", NamedTextColor.GRAY); + } + return Component.text("Search: ", NamedTextColor.GRAY) + .append(Component.text(query, NamedTextColor.GOLD)); + }, ComponentPosition.TOP_RIGHT); + + // Page navigation + addComponent(new PageNavigationComponent<>( + (ctx, res, pg) -> buildPageCommand(ctx, 1), + (ctx, res, pg) -> buildPageCommand(ctx, pg - 1), + (ctx, res, pg) -> buildPageCommand(ctx, pg + 1), + (ctx, res, pg) -> buildPageCommand(ctx, (int) Math.ceil(res.size() / 8.0)) + ), ComponentPosition.BOTTOM_CENTER); + + // Search button at bottom right + addComponent((ctx, paginator, results, pg) -> Component.text("[☀]", paginator.getHighlightColor()) + .hoverEvent(HoverEvent.showText(Component.text("Search for a sign", NamedTextColor.GRAY))) + .clickEvent(ClickEvent.suggestCommand("/sm search ")) + , ComponentPosition.BOTTOM_RIGHT); + } + + private static String buildPageCommand(ICommandContext ctx, int page) { + var query = ctx.getTyped("query", String.class, ""); + if (query == null || query.isBlank()) { + return "/sm page " + page; + } + return "/sm search " + query + " -page " + page; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java deleted file mode 100644 index f83dbdf..0000000 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerGUIService.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.greenfieldmc.core.signmanager.services; - -import net.greenfieldmc.core.IModuleService; -import net.greenfieldmc.core.signmanager.SignFlag; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.Nullable; - -public interface ISignManagerGUIService extends IModuleService { - - /** - * Open the sign manager GUI for a player showing all signs. - * @param player The player to open the GUI for. - */ - void openGUI(Player player); - - /** - * Open the sign manager GUI filtered by a search query. - * @param player The player to open the GUI for. - * @param query The search query. - */ - void openGUIWithSearch(Player player, String query); - - /** - * Open the sign manager GUI filtered by a flag. - * @param player The player to open the GUI for. - * @param flag The flag to filter by. - */ - void openGUIWithFlag(Player player, SignFlag flag); - - /** - * Check if a player has the GUI open. - * @param player The player. - * @return true if the player has the GUI open. - */ - boolean hasGUIOpen(Player player); -} - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java index 7d42835..96d0f29 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java @@ -3,7 +3,6 @@ import net.greenfieldmc.core.IModuleService; import net.greenfieldmc.core.signmanager.SavedSign; import net.greenfieldmc.core.signmanager.SavedSignGroup; -import net.greenfieldmc.core.signmanager.SignFlag; import net.greenfieldmc.core.signmanager.SignManagerEntry; import org.bukkit.entity.Player; import org.jetbrains.annotations.Nullable; @@ -16,20 +15,18 @@ public interface ISignManagerService extends IModuleService * Save a sign from the player's main hand. * @param player The player holding the sign. * @param name The name to save the sign under. - * @param flag An optional flag to categorize the sign. * @return The saved sign, or null if the player is not holding a sign. */ - @Nullable SavedSign saveSignFromHand(Player player, String name, @Nullable SignFlag flag); + @Nullable SavedSign saveSignFromHand(Player player, String name); /** * Save all sign items from the player's hotbar as a group. - * The main hand item is used as the display sign in the GUI. + * The main hand item is used as the display sign. * @param player The player whose hotbar to scan. * @param groupName The group name. - * @param flag An optional flag to categorize the signs. * @return The saved group, or null if no signs found in hotbar or main hand is not a sign. */ - @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName, @Nullable SignFlag flag); + @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName); /** * Delete a saved sign by name. @@ -60,7 +57,7 @@ public interface ISignManagerService extends IModuleService boolean nameExists(String name); /** - * Get all GUI entries (individual signs + groups as single entries). + * Get all GUI entries (individual signs + groups as single entries), sorted alphabetically. * @return A list of all entries. */ List getAllEntries(); @@ -73,11 +70,12 @@ public interface ISignManagerService extends IModuleService List searchEntries(String query); /** - * Filter entries by flag. - * @param flag The flag to filter by. - * @return A list of entries with the given flag. + * Give a player all sign items for an entry by name. + * @param player The player to give signs to. + * @param entryName The name of the sign or group entry. + * @return The entry that was given, or null if not found. */ - List filterByFlag(SignFlag flag); + @Nullable SignManagerEntry giveEntry(Player player, String entryName); /** * Get all distinct group names. @@ -96,4 +94,10 @@ public interface ISignManagerService extends IModuleService * @return A list of sign names. */ List getSignNames(); + + /** + * Get all entry names (signs + groups). + * @return A list of all entry names. + */ + List getAllEntryNames(); } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java index 86e3fff..5282d3d 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java @@ -4,45 +4,65 @@ import com.njdaeger.pdk.command.brigadier.builder.CommandBuilder; import com.njdaeger.pdk.command.brigadier.builder.PdkArgumentTypes; import com.njdaeger.pdk.command.exception.PDKCommandException; +import com.njdaeger.pdk.utils.text.pager.ChatPaginator; import net.greenfieldmc.core.IModuleService; import net.greenfieldmc.core.Module; import net.greenfieldmc.core.ModuleService; import net.greenfieldmc.core.signmanager.SavedSign; -import net.greenfieldmc.core.signmanager.SignFlag; +import net.greenfieldmc.core.signmanager.SignManagerEntry; import net.greenfieldmc.core.signmanager.SignManagerMessages; -import net.greenfieldmc.core.signmanager.arguments.SignFlagArgument; +import net.greenfieldmc.core.signmanager.arguments.SignEntryNameArgument; import net.greenfieldmc.core.signmanager.arguments.SignGroupNameArgument; import net.greenfieldmc.core.signmanager.arguments.SignNameArgument; +import net.greenfieldmc.core.signmanager.paginators.SignManagerPaginator; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.plugin.Plugin; +import java.util.stream.IntStream; + public class SignManagerCommandService extends ModuleService implements IModuleService { private final ISignManagerService signManagerService; - private final ISignManagerGUIService guiService; + private final ChatPaginator paginator = new SignManagerPaginator().build(); - public SignManagerCommandService(Plugin plugin, Module module, ISignManagerService signManagerService, ISignManagerGUIService guiService) { + public SignManagerCommandService(Plugin plugin, Module module, ISignManagerService signManagerService) { super(plugin, module); this.signManagerService = signManagerService; - this.guiService = guiService; } // === User commands === - private void openBrowser(ICommandContext ctx) throws PDKCommandException { - var player = ctx.asPlayer(); - guiService.openGUI(player); + private void listSigns(ICommandContext ctx) throws PDKCommandException { + var entries = signManagerService.getAllEntries(); + if (entries.isEmpty()) ctx.error("There are no saved signs to display."); + int page = ctx.getTyped("pageNumber", 1); + paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender()); } private void searchSigns(ICommandContext ctx) throws PDKCommandException { - var player = ctx.asPlayer(); var query = ctx.getTyped("query", String.class); - guiService.openGUIWithSearch(player, query); + var entries = signManagerService.searchEntries(query); + if (entries.isEmpty()) ctx.error("No signs found matching \"" + query + "\"."); + int page = ctx.getFlag("page", 1); + paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender()); } - private void filterByFlag(ICommandContext ctx) throws PDKCommandException { + private void giveSigns(ICommandContext ctx) throws PDKCommandException { var player = ctx.asPlayer(); - var flag = ctx.getTyped("flag", SignFlag.class); - guiService.openGUIWithFlag(player, flag); + var entryName = ctx.getTyped("entryName", String.class); + var entry = signManagerService.giveEntry(player, entryName); + if (entry == null) { + ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND); + return; + } + if (entry.isGroup()) { + ctx.send(SignManagerMessages.MODULE.append( + Component.text("Gave you " + entry.getSignCount() + " sign(s) from group \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } else { + ctx.send(SignManagerMessages.MODULE.append( + Component.text("Gave you sign \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } } // === Staff commands === @@ -50,14 +70,13 @@ private void filterByFlag(ICommandContext ctx) throws PDKCommandException { private void saveSign(ICommandContext ctx) throws PDKCommandException { var player = ctx.asPlayer(); var name = ctx.getTyped("name", String.class); - var flag = ctx.getTyped("flag", SignFlag.class, null); if (signManagerService.nameExists(name)) { ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); return; } - var sign = signManagerService.saveSignFromHand(player, name, flag); + var sign = signManagerService.saveSignFromHand(player, name); if (sign == null) { ctx.error(SignManagerMessages.ERROR_NOT_HOLDING_SIGN); return; @@ -74,14 +93,13 @@ private void deleteSign(ICommandContext ctx) throws PDKCommandException { private void saveGroup(ICommandContext ctx) throws PDKCommandException { var player = ctx.asPlayer(); var groupName = ctx.getTyped("name", String.class); - var flag = ctx.getTyped("flag", SignFlag.class, null); if (signManagerService.nameExists(groupName)) { ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); return; } - var group = signManagerService.saveGroupFromHotbar(player, groupName, flag); + var group = signManagerService.saveGroupFromHotbar(player, groupName); if (group == null) { ctx.error(SignManagerMessages.ERROR_NO_SIGNS_IN_HOTBAR); return; @@ -105,28 +123,26 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { CommandBuilder.of("sm", "signmanager") .description("Sign Manager - Save, search, and browse signs") .permission("greenfieldcore.signmanager.use") - .defaultExecutor(this::openBrowser) + .defaultExecutor(this::listSigns) .canExecute() // User commands + .then("page") + .then("pageNumber", PdkArgumentTypes.integer(ctx -> IntStream.rangeClosed(1, (int) Math.ceil(signManagerService.getAllEntries().size() / 8.0)).boxed().toList(), () -> "Page number")).executes(this::listSigns).end() .then("search") .then("query", PdkArgumentTypes.greedyString()).executes(this::searchSigns) .end() - .then("flag") - .then("flag", new SignFlagArgument()).executes(this::filterByFlag) + .then("give") + .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::giveSigns) .end() // Staff commands .then("save").permission("greenfieldcore.signmanager.manage") - .then("name", PdkArgumentTypes.string()).canExecute(this::saveSign) - .then("flag", new SignFlagArgument()).executes(this::saveSign) - .end() + .then("name", PdkArgumentTypes.string()).executes(this::saveSign) .end() .then("delete").permission("greenfieldcore.signmanager.manage") .then("signName", new SignNameArgument(signManagerService)).executes(this::deleteSign) .end() .then("savegroup").permission("greenfieldcore.signmanager.manage") - .then("name", PdkArgumentTypes.string()).canExecute(this::saveGroup) - .then("flag", new SignFlagArgument()).executes(this::saveGroup) - .end() + .then("name", PdkArgumentTypes.string()).executes(this::saveGroup) .end() .then("deletegroup").permission("greenfieldcore.signmanager.manage") .then("groupName", new SignGroupNameArgument(signManagerService)).executes(this::deleteGroup) diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java deleted file mode 100644 index 99c78d6..0000000 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerGUIService.java +++ /dev/null @@ -1,332 +0,0 @@ -package net.greenfieldmc.core.signmanager.services; - -import net.greenfieldmc.core.Module; -import net.greenfieldmc.core.ModuleService; -import net.greenfieldmc.core.signmanager.SignFlag; -import net.greenfieldmc.core.signmanager.SignManagerEntry; -import net.greenfieldmc.core.signmanager.SignManagerMessages; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Nullable; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -public class SignManagerGUIService extends ModuleService implements ISignManagerGUIService, Listener { - - private static final int GUI_SIZE = 54; // double chest - private static final int ITEMS_PER_PAGE = 45; // slots 0-44 for sign items - // Bottom row navigation slots - private static final int SLOT_PREV_PAGE = 45; - private static final int SLOT_FILTER_CONSTRUCTION = 46; - private static final int SLOT_FILTER_ROAD = 47; - private static final int SLOT_FILTER_RAIL = 48; - private static final int SLOT_FILTER_TRANSIT = 49; - private static final int SLOT_FILTER_MUNICIPAL = 50; - private static final int SLOT_CLEAR_FILTER = 51; - private static final int SLOT_PAGE_INFO = 52; - private static final int SLOT_NEXT_PAGE = 53; - - private final ISignManagerService signManagerService; - private final Map sessions = new HashMap<>(); - private final java.util.Set rendering = new java.util.HashSet<>(); - - public SignManagerGUIService(Plugin plugin, Module module, ISignManagerService signManagerService) { - super(plugin, module); - this.signManagerService = signManagerService; - } - - @Override - public void tryEnable(Plugin plugin, Module module) throws Exception { - plugin.getServer().getPluginManager().registerEvents(this, plugin); - } - - @Override - public void tryDisable(Plugin plugin, Module module) throws Exception { - for (var entry : sessions.entrySet()) { - var player = Bukkit.getPlayer(entry.getKey()); - if (player != null) player.closeInventory(); - } - sessions.clear(); - } - - @Override - public void openGUI(Player player) { - var entries = signManagerService.getAllEntries(); - var session = new GUISession(entries, null, null); - sessions.put(player.getUniqueId(), session); - renderPage(player, session); - } - - @Override - public void openGUIWithSearch(Player player, String query) { - var entries = signManagerService.searchEntries(query); - var session = new GUISession(entries, query, null); - sessions.put(player.getUniqueId(), session); - renderPage(player, session); - } - - @Override - public void openGUIWithFlag(Player player, SignFlag flag) { - var entries = signManagerService.filterByFlag(flag); - var session = new GUISession(entries, null, flag); - sessions.put(player.getUniqueId(), session); - renderPage(player, session); - } - - @Override - public boolean hasGUIOpen(Player player) { - return sessions.containsKey(player.getUniqueId()); - } - - private void renderPage(Player player, GUISession session) { - var entries = session.entries; - int startIndex = session.page * ITEMS_PER_PAGE; - int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, entries.size()); - - // Build base title - Component title; - if (session.searchQuery != null) { - title = SignManagerMessages.GUI_TITLE_SEARCH.apply(session.searchQuery); - } else if (session.activeFlag != null) { - title = SignManagerMessages.GUI_TITLE_FLAG.apply(session.activeFlag.getDisplayName()); - } else { - title = SignManagerMessages.GUI_TITLE; - } - - // Append page range to title if there are entries on this page - if (startIndex < entries.size()) { - var firstName = entries.get(startIndex).getName().toLowerCase(); - var lastName = entries.get(endIndex - 1).getName().toLowerCase(); - var range = computeSignificantRange(firstName, lastName); - title = title.append(Component.text(" (" + range + ")", NamedTextColor.GRAY)); - } - - var inventory = Bukkit.createInventory(null, GUI_SIZE, title); - - // Fill entry display items - for (int i = startIndex; i < endIndex; i++) { - inventory.setItem(i - startIndex, entries.get(i).toDisplayItemStack()); - } - - // Fill bottom row with glass panes - var filler = createGuiItem(Material.GRAY_STAINED_GLASS_PANE, Component.text(" ")); - for (int i = ITEMS_PER_PAGE; i < GUI_SIZE; i++) { - inventory.setItem(i, filler); - } - - // Previous page button - if (session.page > 0) { - inventory.setItem(SLOT_PREV_PAGE, createGuiItem(Material.ARROW, Component.text("← Previous Page", NamedTextColor.GREEN))); - } - - // Next page button - int totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) ITEMS_PER_PAGE)); - if (session.page < totalPages - 1) { - inventory.setItem(SLOT_NEXT_PAGE, createGuiItem(Material.ARROW, Component.text("Next Page →", NamedTextColor.GREEN))); - } - - // Page info - inventory.setItem(SLOT_PAGE_INFO, createGuiItem(Material.PAPER, - Component.text("Page " + (session.page + 1) + "/" + totalPages, NamedTextColor.YELLOW) - .decoration(TextDecoration.ITALIC, false))); - - // Flag filter buttons - setFlagFilterButton(inventory, SLOT_FILTER_CONSTRUCTION, SignFlag.CONSTRUCTION, session.activeFlag); - setFlagFilterButton(inventory, SLOT_FILTER_ROAD, SignFlag.ROAD, session.activeFlag); - setFlagFilterButton(inventory, SLOT_FILTER_RAIL, SignFlag.RAIL, session.activeFlag); - setFlagFilterButton(inventory, SLOT_FILTER_TRANSIT, SignFlag.TRANSIT, session.activeFlag); - setFlagFilterButton(inventory, SLOT_FILTER_MUNICIPAL, SignFlag.MUNICIPAL, session.activeFlag); - - // Clear filter button - inventory.setItem(SLOT_CLEAR_FILTER, createGuiItem(Material.BARRIER, - Component.text("Clear Filters", NamedTextColor.RED).decoration(TextDecoration.ITALIC, false))); - - session.inventory = inventory; - rendering.add(player.getUniqueId()); - player.openInventory(inventory); - rendering.remove(player.getUniqueId()); - } - - private void setFlagFilterButton(Inventory inventory, int slot, SignFlag flag, @Nullable SignFlag activeFlag) { - boolean isActive = flag == activeFlag; - var material = isActive ? Material.LIME_STAINED_GLASS_PANE : Material.LIGHT_GRAY_STAINED_GLASS_PANE; - var color = isActive ? NamedTextColor.GREEN : NamedTextColor.GRAY; - inventory.setItem(slot, createGuiItem(material, - Component.text(flag.getDisplayName(), color).decoration(TextDecoration.ITALIC, false))); - } - - private ItemStack createGuiItem(Material material, Component name) { - var item = new ItemStack(material, 1); - var meta = item.getItemMeta(); - meta.displayName(name); - item.setItemMeta(meta); - return item; - } - - /** - * Computes the significant character range for two entry names. - * Finds the shortest prefix that distinguishes the first name from the last name. - * e.g. "railsign" and "roadsign" -> "ra-ro" - * "aaa" and "aaa" -> "aaa" - * "bridge" and "bus" -> "br-bu" - */ - private String computeSignificantRange(String first, String last) { - if (first.equals(last)) return first; - - // Find the index where the two names first differ - int shared = 0; - int minLen = Math.min(first.length(), last.length()); - while (shared < minLen && first.charAt(shared) == last.charAt(shared)) { - shared++; - } - - // Include up to one character past the divergence point (minimum 1 char total) - int prefixLen = Math.min(shared + 1, minLen); - - // Ensure at least 1 character - prefixLen = Math.max(prefixLen, 1); - - var firstPrefix = first.substring(0, Math.min(prefixLen, first.length())); - var lastPrefix = last.substring(0, Math.min(prefixLen, last.length())); - - if (firstPrefix.equals(lastPrefix)) { - // Edge case: one name is a prefix of the other, extend to distinguish - firstPrefix = first.substring(0, Math.min(prefixLen + 1, first.length())); - lastPrefix = last.substring(0, Math.min(prefixLen + 1, last.length())); - } - - return firstPrefix + "-" + lastPrefix; - } - - @EventHandler - public void onInventoryClick(InventoryClickEvent event) { - if (!(event.getWhoClicked() instanceof Player player)) return; - var session = sessions.get(player.getUniqueId()); - if (session == null || session.inventory == null) return; - if (!event.getInventory().equals(session.inventory)) return; - - event.setCancelled(true); - - int slot = event.getRawSlot(); - if (slot < 0 || slot >= GUI_SIZE) return; - - // Click on an entry item (slots 0-44) - if (slot < ITEMS_PER_PAGE) { - int index = session.page * ITEMS_PER_PAGE + slot; - if (index < session.entries.size()) { - var entry = session.entries.get(index); - var items = entry.toGiveItemStacks(); - for (var item : items) { - player.getInventory().addItem(item); - } - if (entry.isGroup()) { - player.sendMessage(SignManagerMessages.MODULE.append( - Component.text("Gave you " + items.size() + " sign(s) from group \"" + entry.getName() + "\".", NamedTextColor.GRAY))); - } else { - player.sendMessage(SignManagerMessages.MODULE.append( - Component.text("Gave you sign \"" + entry.getName() + "\".", NamedTextColor.GRAY))); - } - } - return; - } - - // Navigation clicks - switch (slot) { - case SLOT_PREV_PAGE -> { - if (session.page > 0) { - session.page--; - renderPage(player, session); - } - } - case SLOT_NEXT_PAGE -> { - int totalPages = Math.max(1, (int) Math.ceil(session.entries.size() / (double) ITEMS_PER_PAGE)); - if (session.page < totalPages - 1) { - session.page++; - renderPage(player, session); - } - } - case SLOT_FILTER_CONSTRUCTION -> applyFlagFilter(player, session, SignFlag.CONSTRUCTION); - case SLOT_FILTER_ROAD -> applyFlagFilter(player, session, SignFlag.ROAD); - case SLOT_FILTER_RAIL -> applyFlagFilter(player, session, SignFlag.RAIL); - case SLOT_FILTER_TRANSIT -> applyFlagFilter(player, session, SignFlag.TRANSIT); - case SLOT_FILTER_MUNICIPAL -> applyFlagFilter(player, session, SignFlag.MUNICIPAL); - case SLOT_CLEAR_FILTER -> { - session.activeFlag = null; - session.searchQuery = null; - session.entries = signManagerService.getAllEntries(); - session.page = 0; - renderPage(player, session); - } - } - } - - private void applyFlagFilter(Player player, GUISession session, SignFlag flag) { - // Toggle: if already active, clear; otherwise set - if (session.activeFlag == flag) { - session.activeFlag = null; - session.entries = session.searchQuery != null - ? signManagerService.searchEntries(session.searchQuery) - : signManagerService.getAllEntries(); - } else { - session.activeFlag = flag; - if (session.searchQuery != null) { - // Combine search + flag - session.entries = signManagerService.searchEntries(session.searchQuery).stream() - .filter(e -> e.getFlag() == flag) - .toList(); - } else { - session.entries = signManagerService.filterByFlag(flag); - } - } - session.page = 0; - renderPage(player, session); - } - - @EventHandler - public void onInventoryClose(InventoryCloseEvent event) { - if (event.getPlayer() instanceof Player player) { - if (!rendering.contains(player.getUniqueId())) { - sessions.remove(player.getUniqueId()); - } - } - } - - @EventHandler - public void onPlayerQuit(PlayerQuitEvent event) { - sessions.remove(event.getPlayer().getUniqueId()); - } - - /** - * Tracks the state of a player's open Sign Manager GUI. - */ - private static class GUISession { - List entries; - @Nullable String searchQuery; - @Nullable SignFlag activeFlag; - int page; - @Nullable Inventory inventory; - - GUISession(List entries, @Nullable String searchQuery, @Nullable SignFlag activeFlag) { - this.entries = entries; - this.searchQuery = searchQuery; - this.activeFlag = activeFlag; - this.page = 0; - } - } -} - diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java index 4b51e7d..56723d7 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java @@ -4,13 +4,13 @@ import net.greenfieldmc.core.ModuleService; import net.greenfieldmc.core.signmanager.SavedSign; import net.greenfieldmc.core.signmanager.SavedSignGroup; -import net.greenfieldmc.core.signmanager.SignFlag; import net.greenfieldmc.core.signmanager.SignManagerEntry; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; public class SignManagerServiceImpl extends ModuleService implements ISignManagerService { @@ -31,11 +31,11 @@ public void tryDisable(Plugin plugin, Module module) throws Exception { } @Override - public @Nullable SavedSign saveSignFromHand(Player player, String name, @Nullable SignFlag flag) { + public @Nullable SavedSign saveSignFromHand(Player player, String name) { var item = player.getInventory().getItemInMainHand(); if (!SavedSign.isSignMaterial(item.getType())) return null; - var sign = SavedSign.fromItemStack(item, name, flag); + var sign = SavedSign.fromItemStack(item, name); if (sign == null) return null; storageService.saveSign(sign); @@ -44,15 +44,13 @@ public void tryDisable(Plugin plugin, Module module) throws Exception { } @Override - public @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName, @Nullable SignFlag flag) { - // The main hand item becomes the display sign + public @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName) { var mainHandItem = player.getInventory().getItemInMainHand(); if (!SavedSign.isSignMaterial(mainHandItem.getType())) return null; - var displaySign = SavedSign.fromItemStack(mainHandItem, groupName + "_display", flag); + var displaySign = SavedSign.fromItemStack(mainHandItem, groupName + "_display"); if (displaySign == null) return null; - // Collect all sign items from the hotbar as member signs var memberSigns = new ArrayList(); int signCount = 0; for (int slot = 0; slot < 9; slot++) { @@ -60,13 +58,13 @@ public void tryDisable(Plugin plugin, Module module) throws Exception { if (item == null || !SavedSign.isSignMaterial(item.getType())) continue; signCount++; - var memberSign = SavedSign.fromItemStack(item, groupName + "_" + signCount, flag); + var memberSign = SavedSign.fromItemStack(item, groupName + "_" + signCount); if (memberSign != null) memberSigns.add(memberSign); } if (memberSigns.isEmpty()) return null; - var group = new SavedSignGroup(groupName, displaySign, memberSigns, flag); + var group = new SavedSignGroup(groupName, displaySign, memberSigns); storageService.saveGroup(group); storageService.saveDatabase(); return group; @@ -105,7 +103,7 @@ public List getAllEntries() { var entries = new ArrayList(); storageService.getSigns().forEach(sign -> entries.add(SignManagerEntry.ofSign(sign))); storageService.getGroups().forEach(group -> entries.add(SignManagerEntry.ofGroup(group))); - entries.sort(java.util.Comparator.comparing(entry -> entry.getName().toLowerCase())); + entries.sort(Comparator.comparing(entry -> entry.getName().toLowerCase())); return entries; } @@ -119,10 +117,28 @@ public List searchEntries(String query) { } @Override - public List filterByFlag(SignFlag flag) { - return getAllEntries().stream() - .filter(entry -> entry.getFlag() == flag) - .toList(); + public @Nullable SignManagerEntry giveEntry(Player player, String entryName) { + // Check individual signs first + var sign = storageService.getSign(entryName); + if (sign != null) { + var entry = SignManagerEntry.ofSign(sign); + for (var item : entry.toGiveItemStacks()) { + player.getInventory().addItem(item); + } + return entry; + } + + // Check groups + var group = storageService.getGroup(entryName); + if (group != null) { + var entry = SignManagerEntry.ofGroup(group); + for (var item : entry.toGiveItemStacks()) { + player.getInventory().addItem(item); + } + return entry; + } + + return null; } @Override @@ -139,4 +155,13 @@ public List getAllSigns() { public List getSignNames() { return storageService.getSigns().stream().map(SavedSign::getName).toList(); } + + @Override + public List getAllEntryNames() { + var names = new ArrayList(); + storageService.getSigns().forEach(sign -> names.add(sign.getName())); + storageService.getGroups().forEach(group -> names.add(group.getName())); + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java index a678754..437bd49 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java @@ -6,7 +6,6 @@ import net.greenfieldmc.core.ModuleService; import net.greenfieldmc.core.signmanager.SavedSign; import net.greenfieldmc.core.signmanager.SavedSignGroup; -import net.greenfieldmc.core.signmanager.SignFlag; import org.bukkit.Material; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Nullable; @@ -43,10 +42,6 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { // Load groups if (config.hasSection("groups")) { for (var groupName : config.getSection("groups").getKeys(false)) { - var section = config.getSection("groups." + groupName); - var flagStr = section.getString("flag"); - var flag = SignFlag.fromString(flagStr); - // Load display sign var displaySign = loadSign("groups." + groupName + ".displaySign", groupName + "_display"); if (displaySign == null) { @@ -56,6 +51,7 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { // Load member signs var memberSigns = new ArrayList(); + var section = config.getSection("groups." + groupName); if (section.contains("members")) { for (var memberKey : section.getSection("members").getKeys(false)) { var memberSign = loadSign("groups." + groupName + ".members." + memberKey, memberKey); @@ -63,7 +59,7 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { } } - groups.put(groupName.toLowerCase(), new SavedSignGroup(groupName, displaySign, memberSigns, flag)); + groups.put(groupName.toLowerCase(), new SavedSignGroup(groupName, displaySign, memberSigns)); } } } catch (Exception e) { @@ -78,20 +74,17 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { if (material == null) return null; var frontLines = section.getStringList("frontLines"); var backLines = section.getStringList("backLines"); - var flagStr = section.getString("flag"); - var flag = SignFlag.fromString(flagStr); while (frontLines.size() < 4) frontLines.add("\"\""); while (backLines.size() < 4) backLines.add("\"\""); - return new SavedSign(name, material, frontLines, backLines, flag); + return new SavedSign(name, material, frontLines, backLines); } private void persistSign(String path, SavedSign sign) { config.setEntry(path + ".material", sign.getSignMaterial().name()); config.setEntry(path + ".frontLines", sign.getFrontLines()); config.setEntry(path + ".backLines", sign.getBackLines()); - config.setEntry(path + ".flag", sign.getFlag() != null ? sign.getFlag().name() : null); } @Override @@ -137,7 +130,6 @@ public List getGroups(Predicate filter) { public void saveGroup(SavedSignGroup group) { groups.put(group.getName().toLowerCase(), group); var basePath = "groups." + group.getName().toLowerCase(); - config.setEntry(basePath + ".flag", group.getFlag() != null ? group.getFlag().name() : null); persistSign(basePath + ".displaySign", group.getDisplaySign()); // Clear old members and rewrite config.setEntry(basePath + ".members", null); From cd9e740c1fdc7b6731cf543ab1316e8b7ac5e6bc Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:11:27 -0700 Subject: [PATCH 7/8] bug/cleaned up commands --- .../core/signmanager/SignManagerMessages.java | 2 + .../services/ISignManagerService.java | 21 ++++++ .../services/ISignManagerStorageService.java | 1 + .../services/SignManagerCommandService.java | 68 +++++++++++-------- .../services/SignManagerServiceImpl.java | 23 +++++++ .../SignManagerStorageServiceImpl.java | 1 + 6 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java index f7321d3..de87166 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java @@ -4,6 +4,7 @@ import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; +import java.util.function.BiFunction; import java.util.function.Function; import static net.greenfieldmc.core.ComponentUtils.moduleMessage; @@ -21,6 +22,7 @@ public class SignManagerMessages { public static final Function SIGN_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign \"" + name + "\".", NamedTextColor.GRAY)); public static final Function SIGN_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign \"" + name + "\".", NamedTextColor.GRAY)); + public static final BiFunction SIGN_RENAMED = (oldName, newName) -> MODULE.append(Component.text("Renamed sign \"" + oldName + "\" to \"" + newName + "\".", NamedTextColor.GRAY)); public static final Function GROUP_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign group \"" + name + "\".", NamedTextColor.GRAY)); public static final Function GROUP_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign group \"" + name + "\".", NamedTextColor.GRAY)); public static final Function GROUP_SAVED_COUNT = (count) -> MODULE.append(Component.text("Saved " + count + " sign(s) from your hotbar.", NamedTextColor.GRAY)); diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java index 96d0f29..50119b5 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java @@ -28,6 +28,27 @@ public interface ISignManagerService extends IModuleService */ @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName); + /** + * Save a sign to the database. + * @param sign The sign to save. + */ + void saveSign(SavedSign sign); + + /** + * Rename a saved sign. + * @param oldName The current name of the sign. + * @param newName The new name for the sign. + * @return true if the sign was found and renamed. + */ + boolean renameSign(String oldName, String newName); + + /** + * Delete a saved sign or group by name. Tries sign first, then group. + * @param name The name of the entry. + * @return true if the entry was found and deleted. + */ + boolean deleteEntry(String name); + /** * Delete a saved sign by name. * @param name The name of the sign. diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java index 02f81cb..57478fa 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java @@ -38,6 +38,7 @@ default List getSigns() { */ void saveSign(SavedSign sign); + /** * Delete a sign by name. * @param name The name of the sign to delete. diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java index 5282d3d..a308e17 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java @@ -12,7 +12,6 @@ import net.greenfieldmc.core.signmanager.SignManagerEntry; import net.greenfieldmc.core.signmanager.SignManagerMessages; import net.greenfieldmc.core.signmanager.arguments.SignEntryNameArgument; -import net.greenfieldmc.core.signmanager.arguments.SignGroupNameArgument; import net.greenfieldmc.core.signmanager.arguments.SignNameArgument; import net.greenfieldmc.core.signmanager.paginators.SignManagerPaginator; import net.kyori.adventure.text.Component; @@ -66,31 +65,24 @@ private void giveSigns(ICommandContext ctx) throws PDKCommandException { } // === Staff commands === - - private void saveSign(ICommandContext ctx) throws PDKCommandException { + private void saveEntry(ICommandContext ctx) throws PDKCommandException { var player = ctx.asPlayer(); - var name = ctx.getTyped("name", String.class); + var signName = ctx.getTyped("name", String.class); - if (signManagerService.nameExists(name)) { + if (signManagerService.nameExists(signName)) { ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); return; } - var sign = signManagerService.saveSignFromHand(player, name); + var sign = signManagerService.saveSignFromHand(player, signName); if (sign == null) { ctx.error(SignManagerMessages.ERROR_NOT_HOLDING_SIGN); return; } - ctx.send(SignManagerMessages.SIGN_SAVED.apply(name)); + ctx.send(SignManagerMessages.SIGN_SAVED.apply(signName)); } - private void deleteSign(ICommandContext ctx) throws PDKCommandException { - var savedSign = ctx.getTyped("signName", SavedSign.class); - signManagerService.deleteSign(savedSign.getName()); - ctx.send(SignManagerMessages.SIGN_DELETED.apply(savedSign.getName())); - } - - private void saveGroup(ICommandContext ctx) throws PDKCommandException { + private void saveGroupEntry(ICommandContext ctx) throws PDKCommandException { var player = ctx.asPlayer(); var groupName = ctx.getTyped("name", String.class); @@ -108,14 +100,31 @@ private void saveGroup(ICommandContext ctx) throws PDKCommandException { ctx.send(SignManagerMessages.GROUP_SAVED_COUNT.apply(group.getMemberSigns().size())); } - private void deleteGroup(ICommandContext ctx) throws PDKCommandException { - var groupName = ctx.getTyped("groupName", String.class); - boolean deleted = signManagerService.deleteGroup(groupName); + private void renameSign(ICommandContext ctx) throws PDKCommandException { + var savedSign = ctx.getTyped("signName", SavedSign.class); + var newName = ctx.getTyped("newName", String.class); + + if (signManagerService.nameExists(newName)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var oldName = savedSign.getName(); + signManagerService.renameSign(oldName, newName); + ctx.send(SignManagerMessages.SIGN_RENAMED.apply(oldName, newName)); + } + + /** + * Unified delete command. Deletes a sign or group by name. + */ + private void deleteEntry(ICommandContext ctx) throws PDKCommandException { + var entryName = ctx.getTyped("entryName", String.class); + boolean deleted = signManagerService.deleteEntry(entryName); if (!deleted) { - ctx.error(SignManagerMessages.ERROR_GROUP_NOT_FOUND); + ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND); return; } - ctx.send(SignManagerMessages.GROUP_DELETED.apply(groupName)); + ctx.send(SignManagerMessages.SIGN_DELETED.apply(entryName)); } @Override @@ -127,7 +136,8 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { .canExecute() // User commands .then("page") - .then("pageNumber", PdkArgumentTypes.integer(ctx -> IntStream.rangeClosed(1, (int) Math.ceil(signManagerService.getAllEntries().size() / 8.0)).boxed().toList(), () -> "Page number")).executes(this::listSigns).end() + .then("pageNumber", PdkArgumentTypes.integer(ctx -> IntStream.rangeClosed(1, (int) Math.ceil(signManagerService.getAllEntries().size() / 8.0)).boxed().toList(), () -> "Page number")).executes(this::listSigns) + .end() .then("search") .then("query", PdkArgumentTypes.greedyString()).executes(this::searchSigns) .end() @@ -136,16 +146,18 @@ public void tryEnable(Plugin plugin, Module module) throws Exception { .end() // Staff commands .then("save").permission("greenfieldcore.signmanager.manage") - .then("name", PdkArgumentTypes.string()).executes(this::saveSign) - .end() - .then("delete").permission("greenfieldcore.signmanager.manage") - .then("signName", new SignNameArgument(signManagerService)).executes(this::deleteSign) + .then("-group") // Group save flag + .then("name", PdkArgumentTypes.string()).executes(this::saveGroupEntry) + .end() + .then("name", PdkArgumentTypes.string()).executes(this::saveEntry) .end() - .then("savegroup").permission("greenfieldcore.signmanager.manage") - .then("name", PdkArgumentTypes.string()).executes(this::saveGroup) + .then("rename").permission("greenfieldcore.signmanager.manage") + .then("signName", new SignNameArgument(signManagerService)) + .then("newName", PdkArgumentTypes.string()).executes(this::renameSign) + .end() .end() - .then("deletegroup").permission("greenfieldcore.signmanager.manage") - .then("groupName", new SignGroupNameArgument(signManagerService)).executes(this::deleteGroup) + .then("delete").permission("greenfieldcore.signmanager.manage") + .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::deleteEntry) .end() .register(plugin); } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java index 56723d7..b6aeb86 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java @@ -70,6 +70,29 @@ public void tryDisable(Plugin plugin, Module module) throws Exception { return group; } + @Override + public void saveSign(SavedSign sign) { + storageService.saveSign(sign); + storageService.saveDatabase(); + } + + @Override + public boolean renameSign(String oldName, String newName) { + var sign = storageService.getSign(oldName); + if (sign == null) return false; + storageService.deleteSign(oldName); + sign.setName(newName); + storageService.saveSign(sign); + storageService.saveDatabase(); + return true; + } + + @Override + public boolean deleteEntry(String name) { + if (deleteSign(name)) return true; + return deleteGroup(name); + } + @Override public boolean deleteSign(String name) { var sign = storageService.getSign(name); diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java index 437bd49..545082d 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java @@ -108,6 +108,7 @@ public void saveSign(SavedSign sign) { persistSign("signs." + sign.getName().toLowerCase(), sign); } + @Override public void deleteSign(String name) { var removed = signs.remove(name.toLowerCase()); From 23f9e85854b4c60bf7067b959b0cf3524d1e02f2 Mon Sep 17 00:00:00 2001 From: emmatoocold <153875157+emmatoocold@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:36:03 -0700 Subject: [PATCH 8/8] bug/reinstate text centering --- .../core/signmanager/MinecraftFontWidths.java | 107 ++++++++++++++++++ .../core/signmanager/SavedSign.java | 4 +- 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java diff --git a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java new file mode 100644 index 0000000..51cd45d --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java @@ -0,0 +1,107 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Utility for calculating Minecraft text pixel widths and centering text. + * Minecraft uses a variable-width bitmap font where each character has a + * specific pixel width (plus 1px gap between characters). + * Signs center-align text within a 90-pixel-wide area. + */ +public class MinecraftFontWidths { + + // Sign text rendering width in pixels + private static final int SIGN_WIDTH_PIXELS = 90; + + // Space character effective width (glyph 4px + 1px gap) + private static final int SPACE_EFFECTIVE_WIDTH = 5; + + // Default character width if not in the map (6px is the most common glyph width) + private static final int DEFAULT_CHAR_WIDTH = 6; + + // Map of character -> glyph pixel width (not including the 1px inter-char gap) + private static final Map CHAR_WIDTHS = new HashMap<>(); + + static { + // 2px glyphs + for (char c : "!,.:;|i".toCharArray()) CHAR_WIDTHS.put(c, 2); + CHAR_WIDTHS.put('\'', 2); + CHAR_WIDTHS.put('`', 2); + + // 3px glyphs + CHAR_WIDTHS.put('l', 3); + + // 4px glyphs + for (char c : "\"()*I[]{}t".toCharArray()) CHAR_WIDTHS.put(c, 4); + + // 5px glyphs + for (char c : "<>fk".toCharArray()) CHAR_WIDTHS.put(c, 5); + CHAR_WIDTHS.put(' ', 4); // space glyph is 4px + + // 7px glyphs + for (char c : "@~".toCharArray()) CHAR_WIDTHS.put(c, 7); + } + + /** + * Gets the pixel width of a single character glyph in Minecraft's default font. + */ + public static int getCharWidth(char c) { + return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH); + } + + /** + * Calculates the total pixel width of a string in Minecraft's default font. + * Each character contributes its glyph width + 1px gap (except the last character). + */ + public static int getTextWidth(String text) { + if (text == null || text.isEmpty()) return 0; + int width = 0; + for (int i = 0; i < text.length(); i++) { + width += getCharWidth(text.charAt(i)); + if (i < text.length() - 1) width += 1; // 1px gap between characters + } + return width; + } + + /** + * Calculates the pixel width of a Component's plain text content. + */ + public static int getComponentWidth(Component component) { + var plainText = PlainTextComponentSerializer.plainText().serialize(component); + return getTextWidth(plainText); + } + + /** + * Creates a center-padded version of the given component, + * mimicking how signs render center-aligned text. + * + * @param component The component to center. + * @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS). + * @return A new component with leading spaces to approximate center alignment. + */ + public static Component centerText(Component component, int targetWidth) { + int textWidth = getComponentWidth(component); + if (textWidth >= targetWidth) return component; + + int totalPadding = targetWidth - textWidth; + int leftPadding = totalPadding / 2; + + // Each space is SPACE_EFFECTIVE_WIDTH pixels wide (4px glyph + 1px gap) + int spaceCount = leftPadding / SPACE_EFFECTIVE_WIDTH; + if (spaceCount <= 0) return component; + + return Component.text(" ".repeat(spaceCount)).append(component); + } + + /** + * Centers a component using the standard sign width. + */ + public static Component centerText(Component component) { + return centerText(component, SIGN_WIDTH_PIXELS); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java index 92a86a5..1b07055 100644 --- a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java @@ -59,7 +59,7 @@ public List buildHoverLines() { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - lines.add(Component.text(" ").append(component)); + lines.add(MinecraftFontWidths.centerText(component)); } } lines.add(Component.text("── Back ──", NamedTextColor.GRAY)); @@ -67,7 +67,7 @@ public List buildHoverLines() { var component = deserializeLine(line); var plain = PlainTextComponentSerializer.plainText().serialize(component); if (!plain.isBlank()) { - lines.add(Component.text(" ").append(component)); + lines.add(MinecraftFontWidths.centerText(component)); } } return lines;