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, ICommandContext> 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, ICommandContext> 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, ICommandContext> 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;