diff --git a/.gitignore b/.gitignore index 9672d0a..efc83e7 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,12 @@ gradle-app.setting # run-paper run/** +# MockBukkit +logs/* + + +### WIKI ### + # Bun bun.lock @@ -205,4 +211,5 @@ typings/ # dotenv environment variables file .env -.env.test \ No newline at end of file +.env.test + diff --git a/src/main/java/fr/kikoplugins/kikoapi/KikoAPI.java b/src/main/java/fr/kikoplugins/kikoapi/KikoAPI.java index 4ae5771..18b964e 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/KikoAPI.java +++ b/src/main/java/fr/kikoplugins/kikoapi/KikoAPI.java @@ -1,11 +1,15 @@ package fr.kikoplugins.kikoapi; import fr.kikoplugins.kikoapi.lang.Lang; +import fr.kikoplugins.kikoapi.menu.listeners.MenuListener; import fr.kikoplugins.kikoapi.updatechecker.UpdateChecker; import org.bstats.bukkit.Metrics; import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; +import java.util.Arrays; + public class KikoAPI extends JavaPlugin { private static final String MODRINTH_PROJECT_ID = "nwOXHH0K"; private static final int BSTATS_PLUGIN_ID = 29448; @@ -32,10 +36,19 @@ public void onEnable() { if (!isUnitTest()) this.bStats = new Metrics(this, BSTATS_PLUGIN_ID); + registerListeners(); + if (this.getConfig().getBoolean("update-checker.enabled", true)) new UpdateChecker(this, MODRINTH_PROJECT_ID); } + private void registerListeners() { + PluginManager pluginManager = getServer().getPluginManager(); + Arrays.asList( + new MenuListener() + ).forEach(listener -> pluginManager.registerEvents(listener, this)); + } + @Override public void onDisable() { if (!isUnitTest()) diff --git a/src/main/java/fr/kikoplugins/kikoapi/annotations/Knotty.java b/src/main/java/fr/kikoplugins/kikoapi/annotations/Knotty.java new file mode 100644 index 0000000..a46dd93 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/annotations/Knotty.java @@ -0,0 +1,12 @@ +package fr.kikoplugins.kikoapi.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Indicates that the annotated code/element is really badly designed, poorly implemented, + * or generally a "knotty" piece of code that may require special attention or refactoring. + */ +@Retention(RetentionPolicy.SOURCE) +public @interface Knotty { +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/annotations/Overexcited.java b/src/main/java/fr/kikoplugins/kikoapi/annotations/Overexcited.java new file mode 100644 index 0000000..ab4729f --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/annotations/Overexcited.java @@ -0,0 +1,12 @@ +package fr.kikoplugins.kikoapi.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Indicates that the annotated code/element is expensive in terms of resources or performance. + */ +@Retention(RetentionPolicy.SOURCE) +public @interface Overexcited { + String reason() default ""; +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/annotations/Shivery.java b/src/main/java/fr/kikoplugins/kikoapi/annotations/Shivery.java new file mode 100644 index 0000000..a444f42 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/annotations/Shivery.java @@ -0,0 +1,12 @@ +package fr.kikoplugins.kikoapi.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Indicates that the annotated code/element can/will break on minecraft updates or other external changes. + */ +@Retention(RetentionPolicy.SOURCE) +public @interface Shivery { + String reason() default ""; +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/annotations/Sleepy.java b/src/main/java/fr/kikoplugins/kikoapi/annotations/Sleepy.java new file mode 100644 index 0000000..b2889e6 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/annotations/Sleepy.java @@ -0,0 +1,11 @@ +package fr.kikoplugins.kikoapi.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Indicates that the annotated code/element is slow, inefficient. + */ +@Retention(RetentionPolicy.SOURCE) +public @interface Sleepy { +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPICommand.java b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPICommand.java index a8ae4e8..3d18e50 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPICommand.java +++ b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPICommand.java @@ -25,6 +25,7 @@ public static LiteralCommandNode get() { .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi")) .then(reloadCommand()) .then(sendTestMessageCommand()) + .then(KikoAPITestCommand.get()) .build(); } diff --git a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java new file mode 100644 index 0000000..561341b --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java @@ -0,0 +1,100 @@ +package fr.kikoplugins.kikoapi.commands; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import fr.kikoplugins.kikoapi.menu.component.premade.ConfirmationMenu; +import fr.kikoplugins.kikoapi.menu.test.BasicTestMenu; +import fr.kikoplugins.kikoapi.menu.test.DynamicTestMenu; +import fr.kikoplugins.kikoapi.menu.test.PaginatedTestMenu; +import fr.kikoplugins.kikoapi.menu.test.PreviousTestMenu; +import fr.kikoplugins.kikoapi.utils.CommandUtils; +import fr.kikoplugins.kikoapi.utils.ItemBuilder; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +public class KikoAPITestCommand { + private KikoAPITestCommand() { + throw new IllegalStateException("Command class"); + } + + public static LiteralArgumentBuilder get() { + return Commands.literal("test") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test", true)) + .then(basicCommand()) + .then(confirmationCommand()) + .then(dynamicCommand()) + .then(paginatorCommand()) + .then(previousCommand()); + } + + private static LiteralArgumentBuilder basicCommand() { + return Commands.literal("basic") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.basic", true)) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + BasicTestMenu menu = new BasicTestMenu(player); + menu.open(); + + return Command.SINGLE_SUCCESS; + }); + } + + private static LiteralArgumentBuilder confirmationCommand() { + return Commands.literal("confirmation") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.confirmation", true)) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + ConfirmationMenu menu = new ConfirmationMenu( + player, + Component.text("Are you sure ?"), + ItemBuilder.of(Material.LIME_DYE).name(Component.text("Yes", NamedTextColor.GREEN)).build(), + ItemBuilder.of(Material.RED_DYE).name(Component.text("No", NamedTextColor.RED)).build(), + ItemBuilder.of(Material.OAK_SIGN).name(Component.text("This action is irreversible")).build(), + event -> event.getPlayer().sendRichMessage("You clicked Yes"), + event -> event.getPlayer().sendRichMessage("You clicked No") + ); + menu.open(); + + return Command.SINGLE_SUCCESS; + }); + } + + private static LiteralArgumentBuilder dynamicCommand() { + return Commands.literal("dynamic") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.dynamic", true)) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + DynamicTestMenu menu = new DynamicTestMenu(player); + menu.open(); + + return Command.SINGLE_SUCCESS; + }); + } + + private static LiteralArgumentBuilder paginatorCommand() { + return Commands.literal("paginator") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.paginator", true)) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + PaginatedTestMenu menu = new PaginatedTestMenu(player); + menu.open(); + + return Command.SINGLE_SUCCESS; + }); + } + + private static LiteralArgumentBuilder previousCommand() { + return Commands.literal("previous") + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.previous", true)) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + new PreviousTestMenu(player).open(); + + return Command.SINGLE_SUCCESS; + }); + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java new file mode 100644 index 0000000..049f5d2 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -0,0 +1,279 @@ +package fr.kikoplugins.kikoapi.menu; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.annotations.Overexcited; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Abstract base class for creating custom GUI menus. + *

+ * This class provides a framework for building interactive inventory-based menus + * with component-based rendering and event handling. + */ +@NullMarked +public abstract class Menu implements InventoryHolder { + protected final MenuContext context; + private final Player player; + private final Object2ObjectOpenHashMap componentIDs; + @Nullable private Inventory inventory; + @Nullable private MenuComponent root; + + /** + * Constructs a new Menu for the specified player. + * + * @param player the player who will interact with this menu + * @throws NullPointerException if player is null + */ + protected Menu(Player player) { + Preconditions.checkNotNull(player, "player cannot be null"); + + this.player = player; + this.context = new MenuContext(this); + this.componentIDs = new Object2ObjectOpenHashMap<>(); + } + + /** + * Constructs a new Menu with the specified player and context. + * + * @param player the player who will interact with this menu + * @param context the menu context for component interaction + * @throws NullPointerException if player or context is null + */ + protected Menu(Player player, MenuContext context) { + Preconditions.checkNotNull(player, "player cannot be null"); + Preconditions.checkNotNull(context, "context cannot be null"); + + this.player = player; + this.context = context; + this.componentIDs = new Object2ObjectOpenHashMap<>(); + } + + /** + * Opens the menu for the player. + *

+ * This method creates the inventory, initializes the root component, + * renders the menu contents, and opens it for the player. + */ + public void open() { + this.componentIDs.clear(); + context.setMenu(this); + + Component title = this.getTitle(); + this.root = this.getRoot(this.context); + this.inventory = Bukkit.createInventory(this, this.root.getHeight() * 9, title); + + this.root.onAdd(this.context); + this.root.render(this.context); + + this.player.openInventory(this.inventory); + this.onOpen(); + } + + /** + * Reopens or refreshes this menu for the player. + * + *

If this menu is not the current menu in the context, this method calls + * {@link #open()} to (re)initialize and open the menu. If this menu is + * already the current menu, it re-renders the root component and opens the + * existing inventory to refresh the displayed contents.

+ */ + public void reopen() { + if (context.getMenu() != this || this.root == null || this.inventory == null) { + this.open(); + return; + } + + this.root.render(this.context); + + this.player.openInventory(this.inventory); + this.onOpen(); + } + + /** + * Closes the menu for the player. + *

+ * This method performs cleanup and closes the player's inventory. + */ + public void close() { + this.close(InventoryCloseEvent.Reason.PLUGIN); + } + + /** + * Closes the menu for the player with a specified reason. + * + * @param reason the reason for closing the inventory + */ + public void close(InventoryCloseEvent.Reason reason) { + // If root is null, it means the menu was never opened or was already closed, so we can skip onRemove + if (this.root != null) + this.root.onRemove(this.context); + + if (reason == InventoryCloseEvent.Reason.PLUGIN) + this.player.closeInventory(); + + if (this.context.getMenu() == this) + this.context.close(); + + this.onClose(reason); + } + + /** + * Handles click events within the menu. + *

+ * Delegates the click event to the root component and triggers a re-render + * of the menu contents. + * + * @param event the inventory click event to handle + * @throws NullPointerException if event is null + */ + @Overexcited(reason = "Calls render on each click") + public void handleClick(KikoInventoryClickEvent event) { + Preconditions.checkNotNull(event, "event cannot be null"); + + this.root.onClick(event, this.context); + + // Only render if the context is still pointing to this menu + // If onClick triggered the opening/closing of another menu we shouldn't render this menu + // It was causing ArrayIndexOutOfBoundsException and ghost items + if (this.context.getMenu() == this) + this.root.render(this.context); + } + + /** + * Registers a component with a unique identifier for later retrieval. + * + * @param id the unique identifier for the component + * @param component the menu component to register + * @throws NullPointerException if id or component is null + */ + public void registerComponentID(String id, MenuComponent component) { + Preconditions.checkNotNull(id, "id cannot be null"); + Preconditions.checkArgument(!id.isEmpty(), "id cannot be empty"); + Preconditions.checkNotNull(component, "component cannot be null"); + + this.componentIDs.put(id, component); + } + + /** + * Unregisters a component by its unique identifier. + * + * @param id the unique identifier of the component to unregister + * @throws NullPointerException if id is null + */ + public void unregisterComponentID(String id) { + Preconditions.checkNotNull(id, "id cannot be null"); + + this.componentIDs.remove(id); + } + + /** + * Returns the title component for this menu's inventory. + *

+ * This method must be implemented by subclasses to define the menu's title. + * + * @return the title component displayed at the top of the inventory + */ + protected abstract Component getTitle(); + + /** + * Indicates whether the menu can be returned to using the previous menu system. + *

+ * Subclasses can override this method to disable returning to this menu + * from another menu. + * + * @return true if the menu can be returned to, false otherwise + */ + protected boolean canGoBackToThisMenu() { + return true; + } + + /** + * Creates and returns the root component for this menu. + *

+ * This method must be implemented by subclasses to define the menu's layout + * and components. + * + * @param context the menu context for component interaction + * @return the root component that defines the menu's structure + */ + protected abstract MenuComponent getRoot(MenuContext context); + + /** + * Called when the menu is opened. + *

+ * Subclasses can override this method to perform actions when the menu + * is opened. + */ + protected void onOpen() { + + } + + /** + * Called when the menu is closed. + *

+ * Subclasses can override this method to perform actions when the menu + * is closed, such as cleanup or saving state. + * + * @param reason the reason for closing the inventory + */ + protected void onClose(InventoryCloseEvent.Reason reason) { + + } + + /** + * Returns the player associated with this menu. + * + * @return the player who owns this menu + */ + public Player getPlayer() { + return player; + } + + /** + * Returns the menu context for this menu. + * + * @return the menu context used for component interaction + */ + public MenuContext getContext() { + return context; + } + + /** + * Retrieves a registered component by its unique identifier. + * + * @param id the unique identifier of the component + * @return the menu component associated with the given id, or null if not found + * @throws NullPointerException if id is null + */ + @Nullable + public MenuComponent getComponentByID(String id) { + Preconditions.checkNotNull(id, "id cannot be null"); + + return this.componentIDs.get(id); + } + + /** + * Returns the Bukkit inventory associated with this menu. + *

+ * Required implementation of the InventoryHolder interface. + * + * @return the inventory instance for this menu + */ + @Override + public Inventory getInventory() { + if (this.inventory == null) + throw new IllegalStateException("Menu inventory has not been initialized. Did you forget to call open() ?"); + + return inventory; + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java new file mode 100644 index 0000000..fcc3eb2 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java @@ -0,0 +1,191 @@ +package fr.kikoplugins.kikoapi.menu; + +import com.google.common.base.Preconditions; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectMaps; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Represents the context of a menu, managing navigation history and data storage. + *

+ * This class allows for tracking previous menus in a stack-like manner, + * enabling users to navigate back through their menu history. + * It also provides a key-value store for persisting data across menu interactions. + */ +@NullMarked +public class MenuContext { + private static final int MAX_PREVIOUS_MENUS = 64; + + private final Deque

previousMenus; + private final Object2ObjectMap data; + + private Menu menu; + private boolean wasPreviousMenuCall; + private boolean firstMenuSet = true; + + /** + * Constructs a new MenuContext for the specified menu. + * + * @param menu the menu associated with this context + * @throws NullPointerException if menu is null + */ + public MenuContext(Menu menu) { + Preconditions.checkNotNull(menu, "menu cannot be null"); + + this.previousMenus = new ArrayDeque<>(); + this.data = Object2ObjectMaps.synchronize(new Object2ObjectOpenHashMap<>()); + + this.menu = menu; + } + + /** + * Returns the menu associated with this context. + * + * @return the menu instance + */ + public Menu getMenu() { + return menu; + } + + /** + * Returns the player associated with this context's menu. + * + * @return the player who owns the menu + */ + public Player getPlayer() { + return this.menu.getPlayer(); + } + + /** + * Returns the previous menu in the navigation stack. + * + * @return the previous menu instance, or null if there is none + */ + @Nullable + public Menu getPreviousMenu() { + if (this.previousMenus.isEmpty()) + return null; + + this.wasPreviousMenuCall = true; + return previousMenus.pollLast(); + } + + /** + * Checks if there is a previous menu in the navigation stack. + * + * @return true if there is a previous menu, false otherwise + */ + public boolean hasPreviousMenu() { + return !this.previousMenus.isEmpty(); + } + + /** + * Sets the current menu in the context, storing the previous menu if applicable. + * + * @param menu the new menu to set + * @throws NullPointerException if menu is null + */ + void setMenu(Menu menu) { + Preconditions.checkNotNull(menu, "menu cannot be null"); + if (this.firstMenuSet) { + this.firstMenuSet = false; + return; + } + + storeLastMenu(); + + this.menu = menu; + this.wasPreviousMenuCall = false; + } + + /** + * Stores the current menu in the previous menus stack if applicable. + */ + private void storeLastMenu() { + if (this.wasPreviousMenuCall || !this.menu.canGoBackToThisMenu()) + return; + + this.previousMenus.add(this.menu); + if (this.previousMenus.size() > MAX_PREVIOUS_MENUS) + this.previousMenus.removeFirst(); + } + + /** + * Stores a value in the context's data map. + * + * @param key the key to associate with the value + * @param value the value to store + * @throws NullPointerException if key is null + */ + public void set(String key, @Nullable Object value) { + Preconditions.checkNotNull(key, "key cannot be null"); + + this.data.put(key, value); + } + + /** + * Retrieves a value from the context's data map. + * + * @param key the key to look up + * @return the value associated with the key, or null if not found + * @throws NullPointerException if key is null + */ + @Nullable + public Object get(String key) { + Preconditions.checkNotNull(key, "key cannot be null"); + + return this.data.get(key); + } + + /** + * Checks if a key exists in the context's data map. + * + * @param key the key to check for + * @return true if the key exists, false otherwise + * @throws NullPointerException if key is null + */ + public boolean has(String key) { + Preconditions.checkNotNull(key, "key cannot be null"); + + return this.data.containsKey(key); + } + + /** + * Removes a key and its associated value from the context's data map. + * + * @param key the key to remove + * @throws NullPointerException if key is null + */ + public void remove(String key) { + Preconditions.checkNotNull(key, "key cannot be null"); + + this.data.remove(key); + } + + /** + * Clears all data from the context, including the previous menus stack and the data map. + *

+ * This method can be used to reset the context to its initial state, removing all stored + * information and navigation history. + */ + public void clear() { + this.data.clear(); + this.previousMenus.clear(); + } + + /** + * Closes the context and clears all stored data. + *

+ * This method should be called when the menu is being destroyed + * to ensure proper cleanup of resources. + */ + public void close() { + this.clear(); + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java new file mode 100644 index 0000000..1ad55dc --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -0,0 +1,399 @@ +package fr.kikoplugins.kikoapi.menu.component; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import org.bukkit.inventory.ItemStack; +import org.checkerframework.checker.index.qual.NonNegative; +import org.checkerframework.checker.index.qual.Positive; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Abstract base class for all menu components. + *

+ * Components represent individual UI elements within a menu that can be rendered, + * positioned, and interacted with. Each component has a position, size, visibility, + * and enabled state, and can handle click events. + */ +@NullMarked +public abstract class MenuComponent { + @Nullable + private final String id; + + private boolean visible = true; + private boolean enabled = true; + + private int x = 0; + private int y = 0; + + protected final int width, height; + + /** + * Constructs a new MenuComponent with the specified ID. + * + * @param builder the builder containing the component configuration + */ + protected MenuComponent(Builder builder) { + this.id = builder.id; + this.width = builder.width; + this.height = builder.height; + } + + /** + * Converts an inventory slot index to its x-coordinate. + * + * @param slot the slot index + * @return the x-coordinate (0-8) + */ + @NonNegative + public static int toX(int slot) { + return slot % 9; + } + + /** + * Converts an inventory slot index to its y-coordinate. + * + * @param slot the slot index + * @return the y-coordinate (0+) + */ + @NonNegative + public static int toY(int slot) { + return slot / 9; + } + + /** + * Converts x and y coordinates to an inventory slot index. + * + * @param x the x-coordinate + * @param y the y-coordinate + * @return the slot index + */ + @NonNegative + public static int toSlot(int x, int y) { + return y * 9 + x; + } + + /** + * Called when this component is added to a menu. + *

+ * Override this method to perform initialization logic when the component + * becomes part of a menu. + * + * @param context the menu context + */ + public void onAdd(MenuContext context) { + + } + + /** + * Called when this component is removed from a menu. + *

+ * Override this method to perform cleanup logic when the component + * is no longer part of a menu. + * + * @param context the menu context + */ + public void onRemove(MenuContext context) { + + } + + /** + * Handles click events on this component. + *

+ * Override this method to define custom behavior when the component is clicked. + * + * @param event the inventory click event + * @param context the menu context + */ + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + + } + + /** + * Returns a map of slot indices to ItemStacks that this component should display. + *

+ * This method must be implemented by subclasses to define what items + * the component renders at each slot. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + public abstract Int2ObjectMap getItems(MenuContext context); + + /** + * Helper method to create a uniform item map for the component's area. + *

+ * This method fills all slots within the component's widthxheight area + * with the same ItemStack. Returns an empty map if not visible. + * + * @param context the menu context + * @param itemStack the ItemStack to fill the component area with + * @return a map from slot indices to the provided ItemStack + */ + protected Int2ObjectMap getItems(MenuContext context, ItemStack itemStack) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(this.height * this.width); + if (!this.isVisible()) + return items; + + int baseSlot = this.getSlot(); + int rowLength = 9; + + for (int row = 0; row < this.height; row++) { + for (int col = 0; col < this.width; col++) { + int slot = baseSlot + col + (row * rowLength); + items.put(slot, itemStack); + } + } + + return items; + } + + /** + * Returns the set of inventory slot indices that this component occupies. + *

+ * This method must be implemented by subclasses to define which slots + * the component uses for rendering. + * + * @param context the menu context + * @return a set of slot indices + */ + public IntSet getSlots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.isVisible()) + return slots; + + int baseSlot = this.getSlot(); + int rowLength = 9; + + for (int row = 0; row < this.height; row++) { + for (int col = 0; col < this.width; col++) { + int slot = baseSlot + col + (row * rowLength); + slots.add(slot); + } + } + + return slots; + } + + /** + * Renders this component to the menu's inventory. + *

+ * This method retrieves the component's items and slots, then places + * the items into the appropriate inventory slots. + * + * @param context the menu context + * @throws NullPointerException if context is null + */ + public void render(MenuContext context) { + Preconditions.checkNotNull(context, "context cannot be null"); + + if (!this.isVisible()) + return; + + Int2ObjectMap items = this.getItems(context); + IntSet slots = this.getSlots(context); + + for (int slot : slots) { + ItemStack item = items.get(slot); + context.getMenu().getInventory().setItem(slot, item); + } + } + + /** + * Sets the position of this component within the menu grid. + * + * @param x the x-coordinate (0-8 for standard inventory width) + * @param y the y-coordinate (0+ for inventory height) + * @throws IllegalArgumentException if x or y is negative + */ + public void setPosition(@NonNegative int x, @NonNegative int y) { + Preconditions.checkArgument(x >= 0, "x cannot be negative: %s", x); + Preconditions.checkArgument(y >= 0, "y cannot be negative: %s", y); + + this.x = x; + this.y = y; + } + + /** + * Returns the unique identifier of this component. + * + * @return the component ID + */ + @Nullable + public String getID() { + return id; + } + + /** + * Returns the x-coordinate of this component's position. + * + * @return the x-coordinate (0-based) + */ + @NonNegative + public int getX() { + return x; + } + + /** + * Returns the y-coordinate of this component's position. + * + * @return the y-coordinate (0-based) + */ + @NonNegative + public int getY() { + return y; + } + + /** + * Returns the width of this component in slots. + * + * @return the component width + */ + @Positive + public int getWidth() { + return this.width; + } + + /** + * Returns the height of this component in rows. + * + * @return the component height + */ + @Positive + public int getHeight() { + return this.height; + } + + /** + * Returns the inventory slot index for this component's top-left position. + * + * @return the slot index calculated from x and y coordinates + */ + @NonNegative + public int getSlot() { + return y * 9 + x; + } + + /** + * Returns whether this component is currently visible. + * + * @return true if visible, false otherwise + */ + public boolean isVisible() { + return visible; + } + + /** + * Sets the visibility state of this component. + * + * @param visible true to make the component visible, false to hide it + */ + public void setVisible(boolean visible) { + this.visible = visible; + } + + /** + * Returns whether this component is currently enabled. + * + * @return true if enabled, false otherwise + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets the enabled state of this component. + * + * @param enabled true to enable the component, false to disable it + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns whether this component can be interacted with. + *

+ * A component is interactable when it is both visible and enabled. + * + * @return true if the component is interactable, false otherwise + */ + public boolean isInteractable() { + return this.visible && this.enabled; + } + + @SuppressWarnings("unchecked") + protected static class Builder { + @Nullable protected String id; + protected int width = 1; + protected int height = 1; + + /** + * Sets the ID for this component. + * + * @param id the unique identifier for the component + * @return this builder for method chaining + * @throws NullPointerException if id is null + */ + @Contract(value = "_ -> this", mutates = "this") + public T id(String id) { + Preconditions.checkNotNull(id, "id cannot be null"); + + this.id = id; + return (T) this; + } + + /** + * Sets the width of the component in slots. + * + * @param width the width in slots (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if width is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public T width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return (T) this; + } + + /** + * Sets the height of the component in rows. + * + * @param height the height in rows (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if height is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public T height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return (T) this; + } + + /** + * Sets both width and height of the component. + * + * @param width the width in slots (must be positive) + * @param height the height in rows (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if width or height is less than 1 + */ + @Contract(value = "_, _ -> this", mutates = "this") + public T size(@Positive int width, @Positive int height) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.width = width; + this.height = height; + return (T) this; + } + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java new file mode 100644 index 0000000..e8ecb2f --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -0,0 +1,1091 @@ +package fr.kikoplugins.kikoapi.menu.component.container; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.*; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectList; +import it.unimi.dsi.fastutil.objects.ObjectSet; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.checkerframework.checker.index.qual.NonNegative; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; +import java.util.function.Function; + +/** + * A container component that displays multiple components across paginated pages. + *

+ * The Paginator component organizes a list of child components into pages, displaying + * a subset of components based on the current page and the configured page size. + * It provides navigation buttons for moving between pages, including back/next buttons + * and optional first/last page buttons. Navigation buttons can have different appearances + * when disabled (at first/last page). + */ +@NullMarked +public class Paginator extends MenuComponent { + private final ObjectList components; + private final IntList layoutSlots; + private Function backItem, nextItem; + @Nullable private Function firstPageItem, lastPageItem; + @Nullable private Function offBackItem, offNextItem, offFirstPageItem, offLastPageItem; + private int page; + @Nullable private ObjectList cachedPageComponents; + + /** + * Constructs a new Paginator with the specified configuration. + * + * @param builder the builder containing the paginator configuration + */ + private Paginator(Builder builder) { + super(builder); + this.components = new ObjectArrayList<>(builder.components); + + this.backItem = builder.backItem; + this.nextItem = builder.nextItem; + this.offBackItem = builder.offBackItem; + this.offNextItem = builder.offNextItem; + + this.firstPageItem = builder.firstPageItem; + this.lastPageItem = builder.lastPageItem; + this.offFirstPageItem = builder.offFirstPageItem; + this.offLastPageItem = builder.offLastPageItem; + + this.page = builder.page; + + this.layoutSlots = new IntArrayList(width * height); + + // Initial calculation of layout slots + this.updateLayoutSlots(); + } + + /** + * Creates a new Paginator builder instance. + * + * @return a new Paginator.Builder for constructing paginators + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Called when this paginator is added to a menu. + *

+ * Propagates the onAdd event to all child components if the paginator is visible. + * + * @param context the menu context + */ + @Override + public void onAdd(MenuContext context) { + this.components.forEach(component -> { + component.onAdd(context); + + String addedID = component.getID(); + if (addedID != null) + context.getMenu().registerComponentID(addedID, component); + }); + } + + /** + * Called when this paginator is removed from a menu. + *

+ * Cleans up all child components and unregisters their IDs from the menu. + * + * @param context the menu context + */ + @Override + public void onRemove(MenuContext context) { + this.components.forEach(component -> { + component.onRemove(context); + + String removedID = component.getID(); + if (removedID != null) + context.getMenu().unregisterComponentID(removedID); + }); + } + + /** + * Updates the cached list of absolute inventory slots that this paginator controls. + * Should be called whenever the paginator's position (x, y) or dimensions change. + */ + private void updateLayoutSlots() { + this.layoutSlots.clear(); + for (int row = 0; row < this.height; row++) { + for (int col = 0; col < this.width; col++) { + int absX = this.getX() + col; + int absY = this.getY() + row; + + this.layoutSlots.add(MenuComponent.toSlot(absX, absY)); + } + } + } + + @Override + public void setPosition(@NonNegative int x, @NonNegative int y) { + super.setPosition(x, y); + this.updateLayoutSlots(); + } + + /** + * Handles click events within the paginator. + *

+ * Iterates through the current page's components, ensures they are correctly + * positioned, and delegates the event if the click falls within their slots. + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + ObjectList pageComponents = this.getCurrentPageComponents(); + for (int i = 0; i < pageComponents.size(); i++) { + if (i >= this.layoutSlots.size()) + break; // Should not happen if page size matches, but safety check + + MenuComponent component = pageComponents.get(i); + if (component.getSlots(context).contains(event.getSlot())) { + component.onClick(event, context); + return; + } + } + } + + /** + * Returns the items to be displayed by this paginator for the current page. + *

+ * Components are assigned to the pre-calculated layout slots. The component's + * position is updated to match the slot, and its items are then collected. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks for the current page + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + ObjectList pageComponents = this.getCurrentPageComponents(); + + for (int i = 0; i < pageComponents.size(); i++) { + if (i >= this.layoutSlots.size()) break; + + MenuComponent component = pageComponents.get(i); + int slot = this.layoutSlots.getInt(i); + + component.setPosition(MenuComponent.toX(slot), MenuComponent.toY(slot)); + items.putAll(component.getItems(context)); + } + + return items; + } + + /** + * Returns the set of slots that this paginator can occupy. + *

+ * Returns the pre-calculated set of all slots in the pagination grid to ensure + * proper cleanup of the area when pages change. + * + * @param context the menu context + * @return a set of all possible slot indices for this paginator + */ + @Override + public IntSet getSlots(MenuContext context) { + // Return all slots controlled by the paginator grid + return new IntOpenHashSet(this.layoutSlots); + } + + @Override + public void render(MenuContext context) { + this.invalidateCache(); + super.render(context); + } + + /** + * Returns the list of components to display on the current page. + *

+ * This method caches the result to avoid recalculating the component + * list on every render and interaction. The cache is invalidated when + * the page changes. + * + * @return a list of components for the current page + */ + private ObjectList getCurrentPageComponents() { + if (this.cachedPageComponents != null) + return this.cachedPageComponents; + + int maxItemsPerPage = this.width * this.height; + int totalItems = this.components.size(); + + int maxPage = Math.max(1, (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1); + int safePage = Math.min(this.page, maxPage); + if (this.page != safePage) + this.page = safePage; + + int startIndex = Math.min(this.page * maxItemsPerPage, totalItems); + int endIndex = Math.min(startIndex + maxItemsPerPage, totalItems); + + this.cachedPageComponents = new ObjectArrayList<>(this.components.subList(startIndex, endIndex)); + return this.cachedPageComponents; + } + + /** + * Invalidates the cached page components, forcing recalculation on next access. + */ + private void invalidateCache() { + this.cachedPageComponents = null; + } + + /** + * Creates a back navigation button for this paginator. + *

+ * The button navigates to the previous page when clicked. If already on the first + * page and no disabled item is configured, an AIR item is displayed. + * + * @return a Button for going to the previous page + */ + public Button getBackButton() { + return Button.create() + .item(context -> { + if (this.page > 0) + return this.backItem.apply(context); + + if (this.offBackItem != null) + return this.offBackItem.apply(context); + + return ItemStack.of(Material.AIR); + }) + .onClick(event -> { + if (this.page <= 0) + return; + + this.page--; + this.render(event.getContext()); + }) + .build(); + } + + /** + * Creates a next navigation button for this paginator. + *

+ * The button navigates to the next page when clicked. If already on the last + * page and no disabled item is configured, an AIR item is displayed. + * + * @return a Button for going to the next page + */ + public Button getNextButton() { + return Button.create() + .item(context -> { + if (this.page < this.getMaxPage()) + return this.nextItem.apply(context); + + if (this.offNextItem != null) + return this.offNextItem.apply(context); + + return ItemStack.of(Material.AIR); + }) + .onClick(event -> { + if (this.page >= this.getMaxPage()) + return; + + this.page++; + this.render(event.getContext()); + }) + .build(); + } + + /** + * Creates a first page navigation button for this paginator. + *

+ * The button navigates to the first page when clicked. If already on the first + * page and no disabled item is configured, an AIR item is displayed. + * + * @return a Button for going to the first page + */ + public Button getFirstPageButton() { + return Button.create() + .item(context -> { + if (this.page > 0 && this.firstPageItem != null) + return this.firstPageItem.apply(context); + + if (this.offFirstPageItem != null) + return this.offFirstPageItem.apply(context); + + return ItemStack.of(Material.AIR); + }) + .onClick(event -> { + if (this.page <= 0) + return; + + this.page = 0; + this.render(event.getContext()); + }) + .build(); + } + + /** + * Creates a last page navigation button for this paginator. + *

+ * The button navigates to the last page when clicked. If already on the last + * page and no disabled item is configured, an AIR item is displayed. + * + * @return a Button for going to the last page + */ + public Button getLastPageButton() { + return Button.create() + .item(context -> { + if (this.page < this.getMaxPage() && this.lastPageItem != null) + return this.lastPageItem.apply(context); + + if (this.offLastPageItem != null) + return this.offLastPageItem.apply(context); + + return ItemStack.of(Material.AIR); + }) + .onClick(event -> { + int maxPage = this.getMaxPage(); + if (this.page >= maxPage) + return; + + this.page = maxPage; + this.render(event.getContext()); + }) + .build(); + } + + /** + * Calculates the maximum page index (0-based) for this paginator. + * + * @return the highest valid page index, or -1 if no components exist + */ + public int getMaxPage() { + int maxItemsPerPage = this.width * this.height; + int totalItems = this.components.size(); + return (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1; + } + + /** + * Adds a component to the paginator. + * + * @param context the menu context + * @param component the component to add + * @return this paginator for method chaining + * @throws NullPointerException if component is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator add(MenuContext context, MenuComponent component) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(component, "component cannot be null"); + + this.components.add(component); + component.onAdd(context); + String addedID = component.getID(); + if (addedID != null) + context.getMenu().registerComponentID(addedID, component); + + this.invalidateCache(); + return this; + } + + /** + * Adds multiple components to the paginator. + * + * @param context the menu context + * @param components the list of components to add + * @return this paginator for method chaining + * @throws NullPointerException if components is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator addAll(MenuContext context, ObjectList components) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(components, "components cannot be null"); + + for (MenuComponent component : components) + this.add(context, component); + return this; + } + + /** + * Removes a component from the paginator based on the specified slot. + * + * @param context the menu context + * @param slot the slot index of the component to remove + * @return this paginator for method chaining + * @throws NullPointerException if context is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator remove(MenuContext context, @NonNegative int slot) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkArgument(slot >= 0, "slot cannot be less than 0: %s", slot); + + ObjectList pageComponents = this.getCurrentPageComponents(); + for (int i = 0; i < pageComponents.size(); i++) { + if (i >= this.layoutSlots.size()) + break; + + MenuComponent component = pageComponents.get(i); + int targetSlot = this.layoutSlots.getInt(i); + + // Temporarily position to check slots + component.setPosition(MenuComponent.toX(targetSlot), MenuComponent.toY(targetSlot)); + + if (!component.getSlots(context).contains(slot)) + continue; + + return this.remove(context, component); + } + + return this; + } + + /** + * Removes a specific component from the paginator. + * + * @param context the menu context + * @param component the component to remove + * @return this paginator for method chaining + * @throws NullPointerException if component is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator remove(MenuContext context, MenuComponent component) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(component, "component cannot be null"); + + component.onRemove(context); + this.components.remove(component); + String removedID = component.getID(); + if (removedID != null) + context.getMenu().unregisterComponentID(removedID); + this.invalidateCache(); + return this; + } + + /** + * Removes multiple components at the specified indexes from the paginator. + * + * @param context the menu context + * @param indexes the set of indexes of components to remove + * @return this paginator for method chaining + * @throws NullPointerException if indexes is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator removeAll(MenuContext context, IntSet indexes) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(indexes, "indexes cannot be null"); + + int[] sorted = indexes.toIntArray(); + Arrays.sort(sorted); + for (int index : sorted) { + if (index >= this.components.size()) + continue; // The next indexes will always be bigger + + MenuComponent component = this.components.get(index); + this.remove(context, component); + } + + return this; + } + + /** + * Removes multiple components from the paginator. + * + * @param context the menu context + * @param components the set of components to remove + * @return this paginator for method chaining + * @throws NullPointerException if components is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Paginator removeAll(MenuContext context, ObjectSet components) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(components, "components cannot be null"); + + for (MenuComponent component : components) + this.remove(context, component); + return this; + } + + /** + * Clears all components from the paginator. + * + * @return this paginator for method chaining + */ + @Contract(value = "-> this", mutates = "this") + public Paginator clear() { + this.components.clear(); + return this; + } + + /** + * Sets the ItemStack for the enabled back button. + * + * @param backItem the ItemStack for the back button + * @return this paginator for method chaining + * @throws NullPointerException if backItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator backItem(ItemStack backItem) { + Preconditions.checkNotNull(backItem, "backItem cannot be null"); + + this.backItem = context -> backItem; + return this; + } + + /** + * Sets the ItemStack for the enabled next button. + * + * @param nextItem the ItemStack for the next button + * @return this paginator for method chaining + * @throws NullPointerException if nextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator nextItem(ItemStack nextItem) { + Preconditions.checkNotNull(nextItem, "nextItem cannot be null"); + + this.nextItem = context -> nextItem; + return this; + } + + /** + * Sets the ItemStack for the disabled back button. + * + * @param offBackItem the ItemStack for the disabled back button + * @return this paginator for method chaining + * @throws NullPointerException if offBackItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offBackItem(ItemStack offBackItem) { + Preconditions.checkNotNull(offBackItem, "offBackItem cannot be null"); + + this.offBackItem = context -> offBackItem; + return this; + } + + /** + * Sets the ItemStack for the disabled next button. + * + * @param offNextItem the ItemStack for the disabled next button + * @return this paginator for method chaining + * @throws NullPointerException if offNextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offNextItem(ItemStack offNextItem) { + Preconditions.checkNotNull(offNextItem, "offNextItem cannot be null"); + + this.offNextItem = context -> offNextItem; + return this; + } + + /** + * Sets the ItemStack for the enabled first page button. + * + * @param firstPageItem the ItemStack for the first page button + * @return this paginator for method chaining + * @throws NullPointerException if firstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator firstPageItem(ItemStack firstPageItem) { + Preconditions.checkNotNull(firstPageItem, "firstPageItem cannot be null"); + + this.firstPageItem = context -> firstPageItem; + return this; + } + + /** + * Sets the ItemStack for the enabled last page button. + * + * @param lastPageItem the ItemStack for the last page button + * @return this paginator for method chaining + * @throws NullPointerException if lastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator lastPageItem(ItemStack lastPageItem) { + Preconditions.checkNotNull(lastPageItem, "lastPageItem cannot be null"); + + this.lastPageItem = context -> lastPageItem; + return this; + } + + /** + * Sets the ItemStack for the disabled first page button. + * + * @param offFirstPageItem the ItemStack for the disabled first page button + * @return this paginator for method chaining + * @throws NullPointerException if offFirstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offFirstPageItem(ItemStack offFirstPageItem) { + Preconditions.checkNotNull(offFirstPageItem, "offFirstPageItem cannot be null"); + + this.offFirstPageItem = context -> offFirstPageItem; + return this; + } + + /** + * Sets the ItemStack for the disabled last page button. + * + * @param offLastPageItem the ItemStack for the disabled last page button + * @return this paginator for method chaining + * @throws NullPointerException if offLastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offLastPageItem(ItemStack offLastPageItem) { + Preconditions.checkNotNull(offLastPageItem, "offLastPageItem cannot be null"); + + this.offLastPageItem = context -> offLastPageItem; + return this; + } + + /** + * Sets a function to provide the enabled back button ItemStack. + * + * @param backItem function that returns the back button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if backItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator backItem(Function backItem) { + Preconditions.checkNotNull(backItem, "backItem cannot be null"); + + this.backItem = backItem; + return this; + } + + /** + * Sets a function to provide the enabled next button ItemStack. + * + * @param nextItem function that returns the next button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if nextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator nextItem(Function nextItem) { + Preconditions.checkNotNull(nextItem, "nextItem cannot be null"); + + this.nextItem = nextItem; + return this; + } + + /** + * Sets a function to provide the disabled back button ItemStack. + * + * @param offBackItem function that returns the disabled back button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if offBackItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offBackItem(Function offBackItem) { + Preconditions.checkNotNull(offBackItem, "offBackItem cannot be null"); + + this.offBackItem = offBackItem; + return this; + } + + /** + * Sets a function to provide the disabled next button ItemStack. + * + * @param offNextItem function that returns the disabled next button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if offNextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offNextItem(Function offNextItem) { + Preconditions.checkNotNull(offNextItem, "offNextItem cannot be null"); + + this.offNextItem = offNextItem; + return this; + } + + /** + * Sets a function to provide the enabled first page button ItemStack. + * + * @param firstPageItem function that returns the first page button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if firstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator firstPageItem(Function firstPageItem) { + Preconditions.checkNotNull(firstPageItem, "firstPageItem cannot be null"); + + this.firstPageItem = firstPageItem; + return this; + } + + /** + * Sets a function to provide the enabled last page button ItemStack. + * + * @param lastPageItem function that returns the last page button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if lastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator lastPageItem(Function lastPageItem) { + Preconditions.checkNotNull(lastPageItem, "lastPageItem cannot be null"); + + this.lastPageItem = lastPageItem; + return this; + } + + /** + * Sets a function to provide the disabled first page button ItemStack. + * + * @param offFirstPageItem function that returns the disabled first page button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if offFirstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offFirstPageItem(Function offFirstPageItem) { + Preconditions.checkNotNull(offFirstPageItem, "offFirstPageItem cannot be null"); + + this.offFirstPageItem = offFirstPageItem; + return this; + } + + /** + * Sets a function to provide the disabled last page button ItemStack. + * + * @param offLastPageItem function that returns the disabled last page button ItemStack + * @return this paginator for method chaining + * @throws NullPointerException if offLastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator offLastPageItem(Function offLastPageItem) { + Preconditions.checkNotNull(offLastPageItem, "offLastPageItem cannot be null"); + + this.offLastPageItem = offLastPageItem; + return this; + } + + /** + * Sets the initial page index for the paginator. + * + * @param page the initial page index (0-based) + * @return this paginator for method chaining + * @throws IllegalArgumentException if page is negative + */ + @Contract(value = "_ -> this", mutates = "this") + public Paginator page(@NonNegative int page) { + Preconditions.checkArgument(page >= 0, "page cannot be less than 0: %s", page); + + this.page = page; + return this; + } + + /** + * Builder class for constructing Paginator instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private final ObjectList components = new ObjectArrayList<>(); + + private Function backItem = context -> new ItemStack(Material.ARROW); + private Function nextItem = context -> new ItemStack(Material.ARROW); + + @Nullable + private Function firstPageItem, lastPageItem; + @Nullable + private Function offBackItem, offNextItem, offFirstPageItem, offLastPageItem; + + private int page; + + /** + * Adds a component to the paginator. + * + * @param component the component to add + * @return this builder for method chaining + * @throws NullPointerException if component is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder add(MenuComponent component) { + Preconditions.checkNotNull(component, "component cannot be null"); + + this.components.add(component); + return this; + } + + /** + * Adds multiple components to the paginator. + * + * @param components the list of components to add + * @return this builder for method chaining + * @throws NullPointerException if components is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder addAll(ObjectList components) { + Preconditions.checkNotNull(components, "components cannot be null"); + + for (MenuComponent component : components) + this.add(component); + return this; + } + + /** + * Sets the ItemStack for the enabled back button. + * + * @param backItem the ItemStack for the back button + * @return this builder for method chaining + * @throws NullPointerException if backItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder backItem(ItemStack backItem) { + Preconditions.checkNotNull(backItem, "backItem cannot be null"); + + this.backItem = context -> backItem; + return this; + } + + /** + * Sets the ItemStack for the enabled next button. + * + * @param nextItem the ItemStack for the next button + * @return this builder for method chaining + * @throws NullPointerException if nextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder nextItem(ItemStack nextItem) { + Preconditions.checkNotNull(nextItem, "nextItem cannot be null"); + + this.nextItem = context -> nextItem; + return this; + } + + /** + * Sets the ItemStack for the disabled back button. + * + * @param offBackItem the ItemStack for the disabled back button + * @return this builder for method chaining + * @throws NullPointerException if offBackItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offBackItem(ItemStack offBackItem) { + Preconditions.checkNotNull(offBackItem, "offBackItem cannot be null"); + + this.offBackItem = context -> offBackItem; + return this; + } + + /** + * Sets the ItemStack for the disabled next button. + * + * @param offNextItem the ItemStack for the disabled next button + * @return this builder for method chaining + * @throws NullPointerException if offNextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offNextItem(ItemStack offNextItem) { + Preconditions.checkNotNull(offNextItem, "offNextItem cannot be null"); + + this.offNextItem = context -> offNextItem; + return this; + } + + /** + * Sets the ItemStack for the enabled first page button. + * + * @param firstPageItem the ItemStack for the first page button + * @return this builder for method chaining + * @throws NullPointerException if firstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder firstPageItem(ItemStack firstPageItem) { + Preconditions.checkNotNull(firstPageItem, "firstPageItem cannot be null"); + + this.firstPageItem = context -> firstPageItem; + return this; + } + + /** + * Sets the ItemStack for the enabled last page button. + * + * @param lastPageItem the ItemStack for the last page button + * @return this builder for method chaining + * @throws NullPointerException if lastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder lastPageItem(ItemStack lastPageItem) { + Preconditions.checkNotNull(lastPageItem, "lastPageItem cannot be null"); + + this.lastPageItem = context -> lastPageItem; + return this; + } + + /** + * Sets the ItemStack for the disabled first page button. + * + * @param offFirstPageItem the ItemStack for the disabled first page button + * @return this builder for method chaining + * @throws NullPointerException if offFirstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offFirstPageItem(ItemStack offFirstPageItem) { + Preconditions.checkNotNull(offFirstPageItem, "offFirstPageItem cannot be null"); + + this.offFirstPageItem = context -> offFirstPageItem; + return this; + } + + /** + * Sets the ItemStack for the disabled last page button. + * + * @param offLastPageItem the ItemStack for the disabled last page button + * @return this builder for method chaining + * @throws NullPointerException if offLastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offLastPageItem(ItemStack offLastPageItem) { + Preconditions.checkNotNull(offLastPageItem, "offLastPageItem cannot be null"); + + this.offLastPageItem = context -> offLastPageItem; + return this; + } + + /** + * Sets a function to provide the enabled back button ItemStack. + * + * @param backItem function that returns the back button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if backItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder backItem(Function backItem) { + Preconditions.checkNotNull(backItem, "backItem cannot be null"); + + this.backItem = backItem; + return this; + } + + /** + * Sets a function to provide the enabled next button ItemStack. + * + * @param nextItem function that returns the next button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if nextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder nextItem(Function nextItem) { + Preconditions.checkNotNull(nextItem, "nextItem cannot be null"); + + this.nextItem = nextItem; + return this; + } + + /** + * Sets a function to provide the disabled back button ItemStack. + * + * @param offBackItem function that returns the disabled back button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if offBackItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offBackItem(Function offBackItem) { + Preconditions.checkNotNull(offBackItem, "offBackItem cannot be null"); + + this.offBackItem = offBackItem; + return this; + } + + /** + * Sets a function to provide the disabled next button ItemStack. + * + * @param offNextItem function that returns the disabled next button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if offNextItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offNextItem(Function offNextItem) { + Preconditions.checkNotNull(offNextItem, "offNextItem cannot be null"); + + this.offNextItem = offNextItem; + return this; + } + + /** + * Sets a function to provide the enabled first page button ItemStack. + * + * @param firstPageItem function that returns the first page button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if firstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder firstPageItem(Function firstPageItem) { + Preconditions.checkNotNull(firstPageItem, "firstPageItem cannot be null"); + + this.firstPageItem = firstPageItem; + return this; + } + + /** + * Sets a function to provide the enabled last page button ItemStack. + * + * @param lastPageItem function that returns the last page button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if lastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder lastPageItem(Function lastPageItem) { + Preconditions.checkNotNull(lastPageItem, "lastPageItem cannot be null"); + + this.lastPageItem = lastPageItem; + return this; + } + + /** + * Sets a function to provide the disabled first page button ItemStack. + * + * @param offFirstPageItem function that returns the disabled first page button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if offFirstPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offFirstPageItem(Function offFirstPageItem) { + Preconditions.checkNotNull(offFirstPageItem, "offFirstPageItem cannot be null"); + + this.offFirstPageItem = offFirstPageItem; + return this; + } + + /** + * Sets a function to provide the disabled last page button ItemStack. + * + * @param offLastPageItem function that returns the disabled last page button ItemStack + * @return this builder for method chaining + * @throws NullPointerException if offLastPageItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offLastPageItem(Function offLastPageItem) { + Preconditions.checkNotNull(offLastPageItem, "offLastPageItem cannot be null"); + + this.offLastPageItem = offLastPageItem; + return this; + } + + /** + * Sets the initial page index for the paginator. + * + * @param page the initial page index (0-based) + * @return this builder for method chaining + * @throws IllegalArgumentException if page is negative + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder page(@NonNegative int page) { + Preconditions.checkArgument(page >= 0, "page cannot be less than 0: %s", page); + + this.page = page; + return this; + } + + /** + * Builds and returns the configured Paginator instance. + * + * @return a new Paginator with the specified configuration + */ + public Paginator build() { + return new Paginator(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java new file mode 100644 index 0000000..ed27a1d --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -0,0 +1,188 @@ +package fr.kikoplugins.kikoapi.menu.component.display; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; + +/** + * A display component that shows an ItemStack without performing actions. + *

+ * The Icon component is used for displaying decorative or informational items + * in menus. Unlike interactive components, icons typically don't perform actions + * when clicked, though they can optionally play a sound for audio feedback. + */ +@NullMarked +public class Icon extends MenuComponent { + private Function item; + @Nullable + private Sound sound; + + /** + * Constructs a new Icon with the specified configuration. + * + * @param builder the builder containing the icon configuration + */ + private Icon(Builder builder) { + super(builder); + this.item = builder.item; + + this.sound = builder.sound; + } + + /** + * Creates a new Icon builder instance. + * + * @return a new Icon.Builder for constructing icons + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Handles click events on this icon. + *

+ * Icons only provide audio feedback when clicked - they don't perform + * any functional actions. If a sound is configured and the icon is + * interactable, the sound will be played to the player. + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + if (this.sound == null) + return; + + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + } + + /** + * Returns the items to be displayed by this icon. + *

+ * The icon fills all slots within its widthxheight area with the + * same ItemStack. Returns an empty map if not visible. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + return this.getItems(context, this.item.apply(context)); + } + + /** + * Sets the ItemStack to display for this icon. + * + * @param item the ItemStack to display + * @return this icon for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Icon item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets a function to provide the ItemStack for this icon. + * + * @param item function that returns the ItemStack to display + * @return this icon for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Icon item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets the sound to play when the icon is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this icon for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Icon sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Builder class for constructing Icon instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private Function item = context -> ItemStack.of(Material.STONE); + + @Nullable private Sound sound = null; + + /** + * Sets the ItemStack to display for this icon. + * + * @param item the ItemStack to display + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets a function to provide the ItemStack for this icon. + * + * @param item function that returns the ItemStack to display + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets the sound to play when the icon is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Builds and returns the configured Icon instance. + * + * @return a new Icon with the specified configuration + */ + public Icon build() { + return new Icon(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java new file mode 100644 index 0000000..8dec858 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java @@ -0,0 +1,445 @@ +package fr.kikoplugins.kikoapi.menu.component.display; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.utils.Direction; +import it.unimi.dsi.fastutil.ints.Int2ObjectFunction; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.Object2DoubleFunction; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.checkerframework.checker.index.qual.NonNegative; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * A display component that visualizes progress as a bar with different item states. + *

+ * The ProgressBar component displays progress using three types of items: + * - Done items: represent completed progress + * - Current item: represents the current progress position (when not fully complete) + * - Not done items: represent remaining progress + *

+ * The progress can be displayed in four directions: UP, DOWN, LEFT, or RIGHT. + * The percentage value determines how much of the bar is filled. + */ +@NullMarked +public class ProgressBar extends MenuComponent { + private final Direction.Default direction; + private Function doneItem, currentItem, notDoneItem; + private Object2DoubleFunction percentage; + + /** + * Constructs a new ProgressBar with the specified configuration. + * + * @param builder the builder containing the progress bar configuration + */ + private ProgressBar(Builder builder) { + super(builder); + this.doneItem = builder.doneItem; + this.currentItem = builder.currentItem; + this.notDoneItem = builder.notDoneItem; + + this.direction = builder.direction; + this.percentage = builder.percentage; + + } + + /** + * Creates a new ProgressBar builder instance. + * + * @return a new ProgressBar.Builder for constructing progress bars + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Returns the items to be displayed by this progress bar. + *

+ * The progress bar fills slots based on the current percentage value: + * - Slots before the progress position show "done" items + * - The slot at the current progress position shows the "current" item (unless 100% complete) + * - Remaining slots show "not done" items + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(this.width * this.height); + if (!this.isVisible()) + return items; + + double pct = Math.clamp(this.percentage.applyAsDouble(context), 0, 1); + + int total = this.width * this.height; + int done = (int) Math.floor(pct * total); + boolean full = pct >= 1d || done >= total; + + Int2ObjectFunction pick = index -> { + if (index < done) + return this.doneItem.apply(context); + + if (!full && index == done) + return this.currentItem.apply(context); + + return this.notDoneItem.apply(context); + }; + + this.forEachSlot((idx, slot) -> items.put(slot.intValue(), pick.apply(idx))); + return items; + } + + /** + * Iterates through each slot in the progress bar according to the specified direction. + *

+ * The traversal order depends on the direction: + * - RIGHT: left-to-right, top-to-bottom + * - LEFT: right-to-left, top-to-bottom + * - DOWN: top-to-bottom, left-to-right + * - UP: bottom-to-top, left-to-right + * + * @param consumer consumer that accepts (index, slot) pairs + */ + private void forEachSlot(BiConsumer consumer) { + Traversal t = this.traversal(); + int baseSlot = this.getSlot(); + int rowLength = 9; + int idx = 0; + + Range outer = t.rowMajor() ? t.rows() : t.cols(); + Range inner = t.rowMajor() ? t.cols() : t.rows(); + boolean outerIsRow = t.rowMajor(); + + for (int o = outer.start; o != outer.endExclusive; o += outer.step) { + for (int i = inner.start; i != inner.endExclusive; i += inner.step) { + int row = outerIsRow ? o : i; + int col = outerIsRow ? i : o; + consumer.accept(idx++, baseSlot + col + (row * rowLength)); + } + } + } + + /** + * Creates a traversal configuration based on the progress bar's direction. + * + * @return a Traversal object defining how to iterate through the progress bar slots + */ + private Traversal traversal() { + Range rowsRange = new Range(0, this.height, 1); + Range colsRange = new Range(0, this.width, 1); + + return switch (this.direction) { + case UP -> new Traversal(new Range(this.height - 1, -1, -1), colsRange, false); + case LEFT -> new Traversal(rowsRange, new Range(this.width - 1, -1, -1), true); + case RIGHT -> new Traversal(rowsRange, colsRange, true); + case DOWN -> new Traversal(rowsRange, colsRange, false); + }; + } + + /** + * Sets the ItemStack to display for completed sections. + * + * @param doneItem the ItemStack for completed sections + * @return this progress bar for method chaining + * @throws NullPointerException if doneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar doneItem(ItemStack doneItem) { + Preconditions.checkNotNull(doneItem, "doneItem cannot be null"); + + this.doneItem = context -> doneItem; + return this; + } + + /** + * Sets the ItemStack to display for the current progress position. + * + * @param currentItem the ItemStack for the current progress position + * @return this progress bar for method chaining + * @throws NullPointerException if currentItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar currentItem(ItemStack currentItem) { + Preconditions.checkNotNull(currentItem, "currentItem cannot be null"); + + this.currentItem = context -> currentItem; + return this; + } + + /** + * Sets the ItemStack to display for incomplete sections. + * + * @param notDoneItem the ItemStack for incomplete sections + * @return this progress bar for method chaining + * @throws NullPointerException if notDoneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar notDoneItem(ItemStack notDoneItem) { + Preconditions.checkNotNull(notDoneItem, "notDoneItem cannot be null"); + + this.notDoneItem = context -> notDoneItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for completed sections. + * + * @param doneItem function that returns the ItemStack for completed sections + * @return this progress bar for method chaining + * @throws NullPointerException if doneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar doneItem(Function doneItem) { + Preconditions.checkNotNull(doneItem, "doneItem cannot be null"); + + this.doneItem = doneItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the current progress position. + * + * @param currentItem function that returns the ItemStack for the current progress position + * @return this progress bar for method chaining + * @throws NullPointerException if currentItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar currentItem(Function currentItem) { + Preconditions.checkNotNull(currentItem, "currentItem cannot be null"); + + this.currentItem = currentItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for incomplete sections. + * + * @param notDoneItem function that returns the ItemStack for incomplete sections + * @return this progress bar for method chaining + * @throws NullPointerException if notDoneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar notDoneItem(Function notDoneItem) { + Preconditions.checkNotNull(notDoneItem, "notDoneItem cannot be null"); + + this.notDoneItem = notDoneItem; + return this; + } + + /** + * Sets a static percentage value for the progress bar. + * + * @param percentage the progress percentage (0.0 to 1.0) + * @return this progress bar for method chaining + * @throws IllegalArgumentException if percentage is negative + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar percentage(@NonNegative double percentage) { + Preconditions.checkArgument(percentage >= 0, "percentage cannot be negative: %s", percentage); + + this.percentage = context -> percentage; + return this; + } + + /** + * Sets a function to provide the progress percentage. + * + * @param percentage function that returns the progress percentage (0.0 to 1.0) + * @return this progress bar for method chaining + * @throws NullPointerException if percentage is null + */ + @Contract(value = "_ -> this", mutates = "this") + public ProgressBar percentage(Object2DoubleFunction percentage) { + Preconditions.checkNotNull(percentage, "percentage cannot be null"); + + this.percentage = percentage; + return this; + } + + /** + * Record representing a range for iteration with start, end, and step values. + * + * @param start the starting index + * @param endExclusive the ending index (exclusive) + * @param step the step size for iteration + */ + private record Range(@NonNegative int start, int endExclusive, int step) { + + } + + /** + * Record representing a traversal pattern for the progress bar. + * + * @param rows the range for row iteration + * @param cols the range for column iteration + * @param rowMajor whether to iterate rows first (true) or columns first (false) + */ + private record Traversal(Range rows, Range cols, boolean rowMajor) { + + } + + /** + * Builder class for constructing ProgressBar instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private Function doneItem = context -> ItemStack.of(Material.LIME_CONCRETE); + private Function currentItem = context -> ItemStack.of(Material.ORANGE_CONCRETE); + private Function notDoneItem = context -> ItemStack.of(Material.RED_CONCRETE); + + private Direction.Default direction = Direction.Default.RIGHT; + + private Object2DoubleFunction percentage = context -> 0D; + + /** + * Sets the ItemStack to display for completed sections. + * + * @param doneItem the ItemStack for completed sections + * @return this builder for method chaining + * @throws NullPointerException if doneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder doneItem(ItemStack doneItem) { + Preconditions.checkNotNull(doneItem, "doneItem cannot be null"); + + this.doneItem = context -> doneItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for completed sections. + * + * @param doneItem function that returns the ItemStack for completed sections + * @return this builder for method chaining + * @throws NullPointerException if doneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder doneItem(Function doneItem) { + Preconditions.checkNotNull(doneItem, "doneItem cannot be null"); + + this.doneItem = doneItem; + return this; + } + + /** + * Sets the ItemStack to display for the current progress position. + * + * @param currentItem the ItemStack for the current progress position + * @return this builder for method chaining + * @throws NullPointerException if currentItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder currentItem(ItemStack currentItem) { + Preconditions.checkNotNull(currentItem, "currentItem cannot be null"); + + this.currentItem = context -> currentItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the current progress position. + * + * @param currentItem function that returns the ItemStack for the current progress position + * @return this builder for method chaining + * @throws NullPointerException if currentItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder currentItem(Function currentItem) { + Preconditions.checkNotNull(currentItem, "currentItem cannot be null"); + + this.currentItem = currentItem; + return this; + } + + /** + * Sets the ItemStack to display for incomplete sections. + * + * @param notDoneItem the ItemStack for incomplete sections + * @return this builder for method chaining + * @throws NullPointerException if notDoneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder notDoneItem(ItemStack notDoneItem) { + Preconditions.checkNotNull(notDoneItem, "notDoneItem cannot be null"); + + this.notDoneItem = context -> notDoneItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for incomplete sections. + * + * @param notDoneItem function that returns the ItemStack for incomplete sections + * @return this builder for method chaining + * @throws NullPointerException if notDoneItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder notDoneItem(Function notDoneItem) { + Preconditions.checkNotNull(notDoneItem, "notDoneItem cannot be null"); + + this.notDoneItem = notDoneItem; + return this; + } + + /** + * Sets the direction in which the progress bar fills. + * + * @param direction the fill direction (UP, DOWN, LEFT, or RIGHT) + * @return this builder for method chaining + * @throws NullPointerException if direction is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder direction(Direction.Default direction) { + Preconditions.checkNotNull(direction, "direction cannot be null"); + + this.direction = direction; + return this; + } + + /** + * Sets a static percentage value for the progress bar. + * + * @param percentage the progress percentage (0.0 to 1.0) + * @return this builder for method chaining + * @throws IllegalArgumentException if percentage is negative + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder percentage(@NonNegative double percentage) { + Preconditions.checkArgument(percentage >= 0, "percentage cannot be negative: %s", percentage); + + this.percentage = context -> percentage; + return this; + } + + /** + * Sets a function to provide the progress percentage. + * + * @param percentage function that returns the progress percentage (0.0 to 1.0) + * @return this builder for method chaining + * @throws NullPointerException if percentage is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder percentage(Object2DoubleFunction percentage) { + Preconditions.checkNotNull(percentage, "percentage cannot be null"); + + this.percentage = percentage; + return this; + } + + /** + * Builds and returns the configured ProgressBar instance. + * + * @return a new ProgressBar with the specified configuration + */ + public ProgressBar build() { + return new ProgressBar(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java new file mode 100644 index 0000000..7516cf8 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -0,0 +1,664 @@ +package fr.kikoplugins.kikoapi.menu.component.interactive; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.KikoAPI; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import fr.kikoplugins.kikoapi.utils.Task; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.ObjectList; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; +import org.checkerframework.checker.index.qual.Positive; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * A versatile interactive button component with support for animations and dynamic content. + *

+ * The Button component provides a comprehensive set of features including: + * - Multiple click handler types (left, right, shift variants, drop) + * - Animation support with configurable frame sequences and intervals + * - Dynamic content updates at regular intervals + * - Customizable click sounds + * - Multi-slot rendering with configurable dimensions + */ +@NullMarked +public class Button extends MenuComponent { + private Function item; + private final Object2ObjectMap, Consumer> onClickMap; + @Nullable private Sound sound; + + @Nullable private Function> animationFrames; + private int animationInterval; + private boolean stopAnimationOnHide; + @Nullable private BukkitTask animationTask; + private int currentFrame; + + private int updateInterval; + private boolean stopUpdatesOnHide; + @Nullable private BukkitTask updateTask; + + /** + * Constructs a new Button with the specified configuration. + * + * @param builder the builder containing the button configuration + */ + private Button(Builder builder) { + super(builder); + this.item = builder.item; + + this.onClickMap = new Object2ObjectLinkedOpenHashMap<>(builder.onClickMap); + + this.sound = builder.sound; + + this.animationFrames = builder.animationFrames; + this.animationInterval = builder.animationInterval; + this.stopAnimationOnHide = builder.stopAnimationOnHide; + + this.updateInterval = builder.updateInterval; + this.stopUpdatesOnHide = builder.stopUpdatesOnHide; + } + + /** + * Creates a new Button builder instance. + * + * @return a new Button.Builder for constructing buttons + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Called when this button is added to a menu. + *

+ * Starts animation and dynamic update tasks if configured. + * + * @param context the menu context + */ + @Override + public void onAdd(MenuContext context) { + if (this.updateInterval > 0) + this.startUpdates(context); + + if (this.animationFrames != null && this.animationInterval > 0) + this.startAnimation(context); + } + + /** + * Called when this button is removed from a menu. + *

+ * Stops all running tasks to prevent memory leaks and ensure proper cleanup. + * + * @param context the menu context + */ + @Override + public void onRemove(MenuContext context) { + this.stopAnimation(); + this.stopUpdates(); + } + + /** + * Handles click events on this button. + *

+ * The button supports several interaction modes with priority handling: + * 1. Specific click handlers (left, right, shift variants, drop) + * 2. General click handler for other mouse clicks + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + Consumer handler = null; + for (Map.Entry, Consumer> entry : this.onClickMap.entrySet()) { + EnumSet clickTypes = entry.getKey(); + if (!clickTypes.contains(event.getClick())) + continue; + + handler = entry.getValue(); + + // Check for a onClick method usage + // We want to prioritize other more specific method used + // So we wait for another one to maybe overwrite the onClick + if (clickTypes.size() != ClickType.values().length) + break; + } + + if (handler == null) + return; + + handler.accept(event); + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + } + + /** + * Returns the items to be displayed by this button. + *

+ * The button fills all slots within its widthxheight area with the + * current item (static, animated, or dynamic). Returns an empty map if not visible. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + return this.getItems(context, this.getCurrentItem(context)); + } + + /** + * Starts the animation task that cycles through animation frames. + * + * @param context the menu context + */ + private void startAnimation(MenuContext context) { + this.animationTask = Task.syncRepeat(() -> { + if (!isEnabled() || (this.stopAnimationOnHide && !isVisible())) { + stopAnimation(); + return; + } + + if (this.animationFrames == null) { + stopAnimation(); + return; + } + + List frames = this.animationFrames.apply(context); + if (frames.isEmpty()) + return; + + this.currentFrame = (this.currentFrame + 1) % frames.size(); + this.render(context); + }, KikoAPI.getInstance(), this.animationInterval * 50L, this.animationInterval * 50L, TimeUnit.MILLISECONDS); + } + + /** + * Stops the animation task and resets the frame counter. + */ + private void stopAnimation() { + this.currentFrame = 0; + + if (this.animationTask == null || this.animationTask.isCancelled()) + return; + + this.animationTask.cancel(); + this.animationTask = null; + } + + /** + * Starts the dynamic update task that refreshes the button content. + * + * @param context the menu context + */ + private void startUpdates(MenuContext context) { + this.updateTask = Task.syncRepeat(() -> { + if (!isEnabled() || (this.stopUpdatesOnHide && !isVisible())) { + stopUpdates(); + return; + } + + this.render(context); + }, KikoAPI.getInstance(), this.updateInterval * 50L, this.updateInterval * 50L, TimeUnit.MILLISECONDS); + } + + /** + * Stops the dynamic update task. + */ + private void stopUpdates() { + if (this.updateTask == null || this.updateTask.isCancelled()) + return; + + this.updateTask.cancel(); + this.updateTask = null; + } + + /** + * Gets the ItemStack to display based on the current button configuration. + *

+ * Priority order: animation frame (if set and non-empty) → item function + * + * @param context the menu context + * @return the appropriate ItemStack for the current state + */ + private ItemStack getCurrentItem(MenuContext context) { + if (this.animationFrames != null) { + ObjectList frames = this.animationFrames.apply(context); + if (frames.isEmpty()) + return this.item.apply(context); + + return frames.get(this.currentFrame % frames.size()); + } + + return this.item.apply(context); + } + + /** + * Sets the ItemStack to display for this button. + * + * @param item the ItemStack to display + * @return this button for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets a function to provide the ItemStack for this button. + * + * @param item function that returns the ItemStack to display + * @return this button for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets the general click handler for mouse clicks. + * + * @param onClick the click handler + * @return this button for method chaining + * @throws NullPointerException if onClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onClick(Consumer onClick) { + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); + return this; + } + + /** + * Sets the left click handler. + * + * @param onLeftClick the left click handler + * @return this button for method chaining + * @throws NullPointerException if onLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onLeftClick(Consumer onLeftClick) { + Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); + return this; + } + + /** + * Sets the right click handler. + * + * @param onRightClick the right click handler + * @return this button for method chaining + * @throws NullPointerException if onRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onRightClick(Consumer onRightClick) { + Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); + return this; + } + + /** + * Sets the drop action handler. + * + * @param onDrop the drop action handler + * @return this button for method chaining + * @throws NullPointerException if onDrop is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onDrop(Consumer onDrop) { + Preconditions.checkNotNull(onDrop, "onDrop cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.DROP, ClickType.CONTROL_DROP), onDrop); + return this; + } + + /** + * Sets the sound to play when the button is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this button for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Button sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Sets the function providing animation frames for this button. + * + * @param animationFrames function that returns a list of ItemStacks to cycle through + * @return this button for method chaining + * @throws NullPointerException if animationFrames is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button animationFrames(Function> animationFrames) { + Preconditions.checkNotNull(animationFrames, "animationFrames cannot be null"); + + this.animationFrames = animationFrames; + return this; + } + + /** + * Sets the interval between animation frames in ticks. + * + * @param animationInterval ticks between frames (must be positive) + * @return this button for method chaining + * @throws IllegalArgumentException if animationInterval is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public Button animationInterval(@Positive int animationInterval) { + Preconditions.checkArgument(animationInterval >= 1, "animationInterval cannot be less than 1: %s", animationInterval); + + this.animationInterval = animationInterval; + return this; + } + + /** + * Sets whether animation should stop when the button is hidden. + * + * @param stopAnimationOnHide true to stop animation when hidden + * @return this button for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Button stopAnimationOnHide(boolean stopAnimationOnHide) { + this.stopAnimationOnHide = stopAnimationOnHide; + return this; + } + + /** + * Sets the interval between dynamic content updates in ticks. + * + * @param updateInterval ticks between updates (must be positive) + * @return this button for method chaining + * @throws IllegalArgumentException if updateInterval is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public Button updateInterval(@Positive int updateInterval) { + Preconditions.checkArgument(updateInterval >= 1, "updateInterval cannot be less than 1: %s", updateInterval); + + this.updateInterval = updateInterval; + return this; + } + + /** + * Sets whether dynamic updates should stop when the button is hidden. + * + * @param stopUpdatesOnHide true to stop updates when hidden + * @return this button for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Button stopUpdatesOnHide(boolean stopUpdatesOnHide) { + this.stopUpdatesOnHide = stopUpdatesOnHide; + return this; + } + + /** + * Builder class for constructing Button instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private Function item = context -> ItemStack.of(Material.STONE); + + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectLinkedOpenHashMap<>(); + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + Sound.Source.UI, + 1F, + 1F + ); + + @Nullable private Function> animationFrames; + private int animationInterval = -1; + private boolean stopAnimationOnHide = true; + + private int updateInterval = -1; + private boolean stopUpdatesOnHide = false; + + /** + * Sets the ItemStack to display for this button. + * + * @param item the ItemStack to display + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets a function to provide the ItemStack for this button. + * + * @param item function that returns the ItemStack to display + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets the general click handler for mouse clicks. + * + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if onClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onClick(Consumer onClick) { + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); + return this; + } + + /** + * Sets the left click handler. + * + * @param onLeftClick the left click handler + * @return this builder for method chaining + * @throws NullPointerException if onLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onLeftClick(Consumer onLeftClick) { + Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); + return this; + } + + /** + * Sets the right click handler. + * + * @param onRightClick the right click handler + * @return this builder for method chaining + * @throws NullPointerException if onRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onRightClick(Consumer onRightClick) { + Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); + return this; + } + + /** + * Sets the drop action handler. + * + * @param onDrop the drop action handler + * @return this builder for method chaining + * @throws NullPointerException if onDrop is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onDrop(Consumer onDrop) { + Preconditions.checkNotNull(onDrop, "onDrop cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.DROP, ClickType.CONTROL_DROP), onDrop); + return this; + } + + /** + * Sets a click handler for specific click types. + * + * @param clickType the click type to handle + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if clickType or onClick is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder onClick(ClickType clickType, Consumer onClick) { + Preconditions.checkNotNull(clickType, "clickType cannot be null"); + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.of(clickType), onClick); + return this; + } + + /** + * Sets a click handler for multiple click types. + * + * @param clickTypes the click types to handle + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if clickTypes or onClick is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder onClick(EnumSet clickTypes, Consumer onClick) { + Preconditions.checkNotNull(clickTypes, "clickTypes cannot be null"); + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(clickTypes, onClick); + return this; + } + + /** + * Sets the sound to play when the button is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Sets the function providing animation frames for this button. + * + * @param animationFrames function that returns a list of ItemStacks to cycle through + * @return this builder for method chaining + * @throws NullPointerException if animationFrames is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder animationFrames(Function> animationFrames) { + Preconditions.checkNotNull(animationFrames, "animationFrames cannot be null"); + + this.animationFrames = animationFrames; + return this; + } + + /** + * Sets the interval between animation frames in ticks. + * + * @param animationInterval ticks between frames (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if animationInterval is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder animationInterval(@Positive int animationInterval) { + Preconditions.checkArgument(animationInterval >= 1, "animationInterval cannot be less than 1: %s", animationInterval); + + this.animationInterval = animationInterval; + return this; + } + + /** + * Sets whether animation should stop when the button is hidden. + * + * @param stopAnimationOnHide true to stop animation when hidden + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder stopAnimationOnHide(boolean stopAnimationOnHide) { + this.stopAnimationOnHide = stopAnimationOnHide; + return this; + } + + /** + * Sets the interval between dynamic content updates in ticks. + * + * @param updateInterval ticks between updates (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if updateInterval is less than 1 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder updateInterval(@Positive int updateInterval) { + Preconditions.checkArgument(updateInterval >= 1, "updateInterval cannot be less than 1: %s", updateInterval); + + this.updateInterval = updateInterval; + return this; + } + + /** + * Sets whether dynamic updates should stop when the button is hidden. + * + * @param stopUpdatesOnHide true to stop updates when hidden + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder stopUpdatesOnHide(boolean stopUpdatesOnHide) { + this.stopUpdatesOnHide = stopUpdatesOnHide; + return this; + } + + /** + * Builds and returns the configured Button instance. + * + * @return a new Button with the specified configuration + */ + public Button build() { + return new Button(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java new file mode 100644 index 0000000..38d6e18 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -0,0 +1,510 @@ +package fr.kikoplugins.kikoapi.menu.component.interactive; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.KikoAPI; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import fr.kikoplugins.kikoapi.utils.Task; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.EnumSet; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * An interactive button component that responds to double-drop actions. + *

+ * This specialized button component displays one item normally and switches to + * a different "drop item" when a drop action is detected. If another drop action + * occurs within 3 seconds (60 ticks), it triggers a double-drop callback. + * The button supports various click handlers for different interaction types. + */ +@NullMarked +public class DoubleDropButton extends MenuComponent { + private Function item; + private Function dropItem; + + private final Object2ObjectMap, Consumer> onClickMap; + @Nullable private Consumer onDoubleDrop; + + @Nullable private Sound sound; + + @Nullable private BukkitTask dropTask; + + /** + * Constructs a new DoubleDropButton with the specified configuration. + * + * @param builder the builder containing the double drop button configuration + */ + private DoubleDropButton(Builder builder) { + super(builder); + this.item = builder.item; + this.dropItem = builder.dropItem; + + this.onClickMap = new Object2ObjectLinkedOpenHashMap<>(builder.onClickMap); + this.onDoubleDrop = builder.onDoubleDrop; + + this.sound = builder.sound; + } + + /** + * Creates a new DoubleDropButton builder instance. + * + * @return a new DoubleDropBuilder for constructing buttons + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Called when this double drop button is removed from a menu. + *

+ * Cancels any pending drop task to prevent memory leaks and + * ensure proper cleanup. + * + * @param context the menu context + */ + @Override + public void onRemove(MenuContext context) { + if (this.dropTask != null) + this.dropTask.cancel(); + } + + /** + * Handles click events on this double drop button. + *

+ * The double drop button supports several interaction modes with priority handling: + * 1. Drop clicks: First drop enters "drop state", second drop within 3 seconds triggers double-drop + * 2. Specific click handlers (left, right, shift variants, drop) + * 3. General click handler for other mouse clicks + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + ClickType click = event.getClick(); + if (click == ClickType.DROP || click == ClickType.CONTROL_DROP) { + handleDropClick(event, context); + return; + } + + Consumer handler = null; + for (Map.Entry, Consumer> entry : this.onClickMap.entrySet()) { + EnumSet clickTypes = entry.getKey(); + if (!clickTypes.contains(event.getClick())) + continue; + + handler = entry.getValue(); + + // Check for a onClick method usage + // We want to prioritize other more specific method used + // So we wait for another one to maybe overwrite the onClick + if (clickTypes.size() != ClickType.values().length) + break; + } + + if (handler == null) + return; + + handler.accept(event); + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + } + + /** + * Handles drop click events for double-drop functionality. + *

+ * On the first drop click, starts a 3-second timer to enter drop state. + * If a second drop click occurs within this period, triggers the double-drop action. + * + * @param event the inventory click event + * @param context the menu context + */ + private void handleDropClick(KikoInventoryClickEvent event, MenuContext context) { + if (this.dropTask != null) { + this.dropTask.cancel(); + this.dropTask = null; + + if (this.onDoubleDrop != null) + this.onDoubleDrop.accept(event); + } else { + this.dropTask = Task.syncLater(() -> { + this.dropTask = null; + render(context); + }, KikoAPI.getInstance(), 3L, TimeUnit.SECONDS); + } + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + } + + /** + * Returns the items to be displayed by this double drop button. + *

+ * The double drop button fills all slots within its widthxheight area with the + * current item (normal or drop state). Returns an empty map if not visible. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + return this.getItems(context, this.getCurrentItem(context)); + } + + /** + * Sets the ItemStack to display in normal state. + * + * @param item the ItemStack for normal state + * @return this double drop button for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets the ItemStack to display in drop state. + * + * @param dropItem the ItemStack for drop state + * @return this double drop button for method chaining + * @throws NullPointerException if dropItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton dropItem(ItemStack dropItem) { + Preconditions.checkNotNull(dropItem, "dropItem cannot be null"); + + this.dropItem = context -> dropItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for normal state. + * + * @param item function that returns the ItemStack for normal state + * @return this double drop button for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets a function to provide the ItemStack for drop state. + * + * @param dropItem function that returns the ItemStack for drop state + * @return this double drop button for method chaining + * @throws NullPointerException if dropItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton dropItem(Function dropItem) { + Preconditions.checkNotNull(dropItem, "dropItem cannot be null"); + + this.dropItem = dropItem; + return this; + } + + /** + * Sets the general click handler for mouse clicks. + * + * @param onClick the click handler + * @return this double drop button for method chaining + * @throws NullPointerException if onClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onClick(Consumer onClick) { + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); + return this; + } + + /** + * Sets the left click handler. + * + * @param onLeftClick the left click handler + * @return this double drop button for method chaining + * @throws NullPointerException if onLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onLeftClick(Consumer onLeftClick) { + Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); + return this; + } + + /** + * Sets the right click handler. + * + * @param onRightClick the right click handler + * @return this double drop button for method chaining + * @throws NullPointerException if onRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onRightClick(Consumer onRightClick) { + Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); + return this; + } + + /** + * Sets the double-drop action handler. + * + * @param onDoubleDrop the double-drop handler + * @return this double drop button for method chaining + * @throws NullPointerException if onDoubleDrop is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onDoubleDrop(Consumer onDoubleDrop) { + Preconditions.checkNotNull(onDoubleDrop, "onDoubleDrop cannot be null"); + + this.onDoubleDrop = onDoubleDrop; + return this; + } + + /** + * Sets the sound to play when the button is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this double drop button for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Gets the ItemStack to display based on the current button state. + * + * @param context the menu context + * @return the normal item if no drop task is active, otherwise the drop item + */ + private ItemStack getCurrentItem(MenuContext context) { + return this.dropTask == null ? this.item.apply(context) : this.dropItem.apply(context); + } + + /** + * Builder class for constructing DoubleDropButton instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private Function item = context -> ItemStack.of(Material.STONE); + private Function dropItem = context -> ItemStack.of(Material.DIRT); + + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectLinkedOpenHashMap<>(); + @Nullable private Consumer onDoubleDrop; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + Sound.Source.UI, + 1F, + 1F + ); + + /** + * Sets the ItemStack to display in normal state. + * + * @param item the ItemStack for normal state + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(ItemStack item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = context -> item; + return this; + } + + /** + * Sets the ItemStack to display in drop state. + * + * @param dropItem the ItemStack for drop state + * @return this builder for method chaining + * @throws NullPointerException if dropItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder dropItem(ItemStack dropItem) { + Preconditions.checkNotNull(dropItem, "dropItem cannot be null"); + + this.dropItem = context -> dropItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for normal state. + * + * @param item function that returns the ItemStack for normal state + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder item(Function item) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.item = item; + return this; + } + + /** + * Sets a function to provide the ItemStack for drop state. + * + * @param dropItem function that returns the ItemStack for drop state + * @return this builder for method chaining + * @throws NullPointerException if dropItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder dropItem(Function dropItem) { + Preconditions.checkNotNull(dropItem, "dropItem cannot be null"); + + this.dropItem = dropItem; + return this; + } + + /** + * Sets the general click handler for mouse clicks. + * + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if onClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onClick(Consumer onClick) { + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); + return this; + } + + /** + * Sets the left click handler. + * + * @param onLeftClick the left click handler + * @return this builder for method chaining + * @throws NullPointerException if onLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onLeftClick(Consumer onLeftClick) { + Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); + return this; + } + + /** + * Sets the right click handler. + * + * @param onRightClick the right click handler + * @return this builder for method chaining + * @throws NullPointerException if onRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onRightClick(Consumer onRightClick) { + Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); + + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); + return this; + } + + /** + * Sets a click handler for specific click types. + * + * @param clickType the click type to handle + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if clickType or onClick is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder onClick(ClickType clickType, Consumer onClick) { + Preconditions.checkNotNull(clickType, "clickType cannot be null"); + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(EnumSet.of(clickType), onClick); + return this; + } + + /** + * Sets a click handler for multiple click types. + * + * @param clickTypes the click types to handle + * @param onClick the click handler + * @return this builder for method chaining + * @throws NullPointerException if clickTypes or onClick is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder onClick(EnumSet clickTypes, Consumer onClick) { + Preconditions.checkNotNull(clickTypes, "clickTypes cannot be null"); + Preconditions.checkNotNull(onClick, "onClick cannot be null"); + + this.onClickMap.put(clickTypes, onClick); + return this; + } + + /** + * Sets the double-drop action handler. + * + * @param onDoubleDrop the double-drop handler + * @return this builder for method chaining + * @throws NullPointerException if onDoubleDrop is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onDoubleDrop(Consumer onDoubleDrop) { + Preconditions.checkNotNull(onDoubleDrop, "onDoubleDrop cannot be null"); + + this.onDoubleDrop = onDoubleDrop; + return this; + } + + /** + * Sets the sound to play when the double drop button is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Builds and returns the configured DoubleDropButton instance. + * + * @return a new DoubleDropButton with the specified configuration + */ + public DoubleDropButton build() { + return new DoubleDropButton(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java new file mode 100644 index 0000000..edb82ff --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -0,0 +1,442 @@ +package fr.kikoplugins.kikoapi.menu.component.interactive; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectList; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.bukkit.inventory.ItemStack; +import org.checkerframework.checker.index.qual.NonNegative; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * An interactive selector component that allows cycling through multiple options. + *

+ * The Selector component displays a single item representing the currently selected + * option and allows players to cycle through available options using left-click + * (next) and right-click (previous). It supports customizable options, default + * selection, change callbacks, and click sounds. + * + * @param the type of values associated with selector options + */ +@NullMarked +public class Selector extends MenuComponent { + private final ObjectList> options; + @Nullable + private Function defaultOption; + @Nullable + private Consumer> onSelectionChange; + @Nullable + private Sound sound; + private int currentIndex; + + /** + * Constructs a new Selector with the specified configuration. + * + * @param builder the builder containing the selector configuration + */ + private Selector(Builder builder) { + super(builder); + this.options = new ObjectArrayList<>(builder.options); + this.defaultOption = builder.defaultOption; + this.onSelectionChange = builder.onSelectionChange; + this.currentIndex = builder.defaultIndex; + + this.sound = builder.sound; + } + + /** + * Creates a new Selector builder instance. + * + * @param the type of values for selector options + * @return a new Selector.Builder for constructing selectors + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder<>(); + } + + /** + * Called when this selector is added to a menu. + *

+ * If a default option function is configured, it applies the default + * selection based on the menu context. + * + * @param context the menu context + */ + @Override + public void onAdd(MenuContext context) { + if (this.defaultOption == null) + return; + + T appliedDefaultOption = this.defaultOption.apply(context); + this.setSelection(appliedDefaultOption); + } + + /** + * Handles click events on this selector. + *

+ * Left-click advances to the next option, right-click goes to the previous option. + * Other click types are ignored. When the selection changes, the configured + * callback is invoked with details about the change. + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + int operation = switch (event.getClick()) { + case LEFT, SHIFT_LEFT, DOUBLE_CLICK -> 1; + case RIGHT, SHIFT_RIGHT -> -1; + default -> 0; + }; + + if (operation == 0) + return; + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + + Option oldOption = this.getCurrentOption(); + int oldIndex = this.currentIndex; + this.currentIndex = Math.floorMod(this.currentIndex + operation, this.options.size()); + Option newOption = this.getCurrentOption(); + + if (this.onSelectionChange == null || oldIndex == this.currentIndex) + return; + + SelectionChangeEvent selectionChangeEvent = new SelectionChangeEvent<>( + context, + oldOption.value, + newOption.value, + oldIndex, + this.currentIndex + ); + + this.onSelectionChange.accept(selectionChangeEvent); + } + + /** + * Returns the items to be displayed by this selector. + *

+ * The selector fills all slots within its widthxheight area with the + * current selection's item. Returns an empty map if not visible. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + return this.getItems(context, this.getCurrentItem(context)); + } + + /** + * Sets the current selection to the option with the specified value. + * + * @param value the value to select + */ + private void setSelection(T value) { + for (int i = 0; i < this.options.size(); i++) { + if (Objects.equals(this.options.get(i).value, value)) { + this.currentIndex = i; + break; + } + } + } + + /** + * Gets the currently selected option. + * + * @return the current Option instance + */ + private Option getCurrentOption() { + return this.options.get(this.currentIndex); + } + + /** + * Gets the ItemStack to display for the current selection. + * + * @param context the menu context + * @return the ItemStack for the current option + */ + private ItemStack getCurrentItem(MenuContext context) { + return this.getCurrentOption().item.apply(context); + } + + /** + * Adds an option to the selector with a static ItemStack. + * + * @param item the ItemStack to display for this option + * @param value the value associated with this option (may be null) + * @return this selector for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Selector addOption(ItemStack item, @Nullable T value) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.options.add(new Option<>(context -> item, value)); + return this; + } + + /** + * Adds an option to the selector with a dynamic ItemStack function. + * + * @param item function that provides the ItemStack for this option + * @param value the value associated with this option (may be null) + * @return this selector for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Selector addOption(Function item, @Nullable T value) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.options.add(new Option<>(item, value)); + return this; + } + + /** + * Removes the option with the specified value from the selector. + * + * @param value the value of the option to remove + * @return this selector for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Selector removeOption(@Nullable T value) { + int removedIndex = -1; + for (int i = 0; i < this.options.size(); i++) { + if (Objects.equals(this.options.get(i).value, value)) { + removedIndex = i; + break; + } + } + + if (removedIndex == -1) + return this; + + if (this.options.size() == 1) + throw new IllegalStateException("Cannot remove the last option from the selector"); + + this.options.remove(removedIndex); + + if (removedIndex < this.currentIndex) + this.currentIndex--; + else if (this.currentIndex >= this.options.size()) + this.currentIndex = Math.max(0, this.options.size() - 1); + + return this; + } + + /** + * Sets the callback to invoke when the selection changes. + * + * @param consumer the selection change callback + * @return this selector for method chaining + * @throws NullPointerException if consumer is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Selector onSelectionChange(Consumer> consumer) { + Preconditions.checkNotNull(consumer, "consumer cannot be null"); + + this.onSelectionChange = consumer; + return this; + } + + /** + * Sets a function to determine the default option based on context. + * + * @param defaultOption function that returns the default value to select + * @return this selector for method chaining + * @throws NullPointerException if defaultOption is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Selector defaultOption(Function defaultOption) { + Preconditions.checkNotNull(defaultOption, "defaultOption cannot be null"); + + this.defaultOption = defaultOption; + return this; + } + + /** + * Sets the sound to play when the selector is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this selector for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Selector sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Event record containing information about a selection change. + * + * @param context the menu context where the change occurred + * @param oldValue the previously selected value + * @param newValue the newly selected value + * @param oldIndex the previous selection index + * @param newIndex the new selection index + * @param the type of values in the selector + */ + public record SelectionChangeEvent(MenuContext context, @Nullable T oldValue, @Nullable T newValue, + @NonNegative int oldIndex, @NonNegative int newIndex) { + + } + + /** + * Record representing a selectable option in the selector. + * + * @param item function that provides the ItemStack to display for this option + * @param value the value associated with this option (may be null) + * @param the type of the option value + */ + public record Option(Function item, @Nullable T value) { + + } + + /** + * Builder class for constructing Selector instances with a fluent interface. + * + * @param the type of values for selector options + */ + public static class Builder extends MenuComponent.Builder> { + private final ObjectList> options = new ObjectArrayList<>(); + @Nullable + private Function defaultOption; + @Nullable + private Consumer> onSelectionChange; + + private int defaultIndex = 0; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + Sound.Source.UI, + 1F, + 1F + ); + + /** + * Adds an option to the selector with a static ItemStack. + * + * @param item the ItemStack to display for this option + * @param value the value associated with this option (may be null) + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder addOption(ItemStack item, @Nullable T value) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.options.add(new Option<>(context -> item, value)); + return this; + } + + /** + * Adds an option to the selector with a dynamic ItemStack function. + * + * @param item function that provides the ItemStack for this option + * @param value the value associated with this option (may be null) + * @return this builder for method chaining + * @throws NullPointerException if item is null + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder addOption(Function item, @Nullable T value) { + Preconditions.checkNotNull(item, "item cannot be null"); + + this.options.add(new Option<>(item, value)); + return this; + } + + /** + * Sets the default selected index. + * + * @param index the index to select by default (0-based) + * @return this builder for method chaining + * @throws IllegalArgumentException if index is negative + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder defaultIndex(@NonNegative int index) { + Preconditions.checkArgument(index >= 0, "index cannot be negative: %s", index); + + this.defaultIndex = index; + return this; + } + + /** + * Sets the callback to invoke when the selection changes. + * + * @param consumer the selection change callback + * @return this builder for method chaining + * @throws NullPointerException if consumer is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onSelectionChange(Consumer> consumer) { + Preconditions.checkNotNull(consumer, "consumer cannot be null"); + + this.onSelectionChange = consumer; + return this; + } + + /** + * Sets a function to determine the default option based on context. + * + * @param defaultOption function that returns the default value to select + * @return this builder for method chaining + * @throws NullPointerException if defaultOption is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder defaultOption(Function defaultOption) { + Preconditions.checkNotNull(defaultOption, "defaultOption cannot be null"); + + this.defaultOption = defaultOption; + return this; + } + + /** + * Sets the sound to play when the selector is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Builds and returns the configured Selector instance. + * + * @return a new Selector with the specified configuration + */ + public Selector build() { + Preconditions.checkArgument( + !this.options.isEmpty(), + "Selector must have at least one option" + ); + + Preconditions.checkArgument( + this.defaultIndex < this.options.size(), + "defaultIndex (%s) must be less than options size (%s)", + this.defaultIndex, this.options.size() + ); + + return new Selector<>(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java new file mode 100644 index 0000000..47fc75b --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -0,0 +1,334 @@ +package fr.kikoplugins.kikoapi.menu.component.interactive; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * An interactive toggle component that switches between two states (on/off). + *

+ * The Toggle component displays different items based on its current state and + * automatically switches between states when clicked. It supports customizable + * on/off items, click sounds, and can span multiple slots with configurable + * width and height. + */ +@NullMarked +public class Toggle extends MenuComponent { + private Function onItem, offItem; + @Nullable + private Consumer onToggle; + @Nullable + private Sound sound; + private boolean currentState; + + /** + * Constructs a new Toggle with the specified configuration. + * + * @param builder the builder containing the toggle configuration + */ + private Toggle(Builder builder) { + super(builder); + this.onItem = builder.onItem; + this.offItem = builder.offItem; + + this.onToggle = builder.onToggle; + + this.sound = builder.sound; + + this.currentState = builder.currentState; + } + + /** + * Creates a new Toggle builder instance. + * + * @return a new Toggle.Builder for constructing toggles + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Handles click events on this toggle. + *

+ * When clicked, the toggle switches its state, plays a sound (if configured), + * and triggers a re-render to update the displayed item. + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); + + this.currentState = !this.currentState; + + if (this.onToggle != null) { + ToggleEvent toggleEvent = new ToggleEvent(event, this.currentState); + this.onToggle.accept(toggleEvent); + } + } + + /** + * Returns the items to be displayed by this toggle. + *

+ * The toggle fills all slots within its widthxheight area with the + * current state item (on or off). Returns an empty map if not visible. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + return this.getItems(context, this.getCurrentItem(context)); + } + + /** + * Gets the ItemStack to display based on the current toggle state. + * + * @param context the menu context + * @return the appropriate ItemStack for the current state + */ + private ItemStack getCurrentItem(MenuContext context) { + return currentState ? this.onItem.apply(context) : this.offItem.apply(context); + } + + /** + * Sets the ItemStack to display when the toggle is in the "on" state. + * + * @param onItem the ItemStack for the "on" state + * @return this toggle for method chaining + * @throws NullPointerException if onItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle onItem(ItemStack onItem) { + Preconditions.checkNotNull(onItem, "onItem cannot be null"); + + this.onItem = context -> onItem; + return this; + } + + /** + * Sets the ItemStack to display when the toggle is in the "off" state. + * + * @param offItem the ItemStack for the "off" state + * @return this toggle for method chaining + * @throws NullPointerException if offItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle offItem(ItemStack offItem) { + Preconditions.checkNotNull(offItem, "offItem cannot be null"); + + this.offItem = context -> offItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the "on" state. + * + * @param onItem function that returns the ItemStack for the "on" state + * @return this toggle for method chaining + * @throws NullPointerException if onItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle onItem(Function onItem) { + Preconditions.checkNotNull(onItem, "onItem cannot be null"); + + this.onItem = onItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the "off" state. + * + * @param offItem function that returns the ItemStack for the "off" state + * @return this toggle for method chaining + * @throws NullPointerException if offItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle offItem(Function offItem) { + Preconditions.checkNotNull(offItem, "offItem cannot be null"); + + this.offItem = offItem; + return this; + } + + /** + * Sets the toggle state change handler. + * + * @param onToggle the consumer to handle toggle state changes + * @return this toggle for method chaining + * @throws NullPointerException if onToggle is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle onToggle(Consumer onToggle) { + Preconditions.checkNotNull(onToggle, "onToggle cannot be null"); + + this.onToggle = onToggle; + return this; + } + + /** + * Sets the sound to play when the toggle is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this toggle for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Sets the initial state of the toggle. + * + * @param state true for "on" state, false for "off" state + * @return this toggle for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle currentState(boolean state) { + this.currentState = state; + return this; + } + + public record ToggleEvent(KikoInventoryClickEvent clickEvent, boolean newState) {} + + /** + * Builder class for constructing Toggle instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private Function onItem = context -> ItemStack.of(Material.LIME_DYE); + private Function offItem = context -> ItemStack.of(Material.RED_DYE); + + @Nullable + private Consumer onToggle; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + Sound.Source.UI, + 1F, + 1F + ); + + private boolean currentState; + + /** + * Sets the ItemStack to display when the toggle is in the "on" state. + * + * @param onItem the ItemStack for the "on" state + * @return this builder for method chaining + * @throws NullPointerException if onItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onItem(ItemStack onItem) { + Preconditions.checkNotNull(onItem, "onItem cannot be null"); + + this.onItem = context -> onItem; + return this; + } + + /** + * Sets the ItemStack to display when the toggle is in the "off" state. + * + * @param offItem the ItemStack for the "off" state + * @return this builder for method chaining + * @throws NullPointerException if offItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offItem(ItemStack offItem) { + Preconditions.checkNotNull(offItem, "offItem cannot be null"); + + this.offItem = context -> offItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the "on" state. + * + * @param onItem function that returns the ItemStack for the "on" state + * @return this builder for method chaining + * @throws NullPointerException if onItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onItem(Function onItem) { + Preconditions.checkNotNull(onItem, "onItem cannot be null"); + + this.onItem = onItem; + return this; + } + + /** + * Sets a function to provide the ItemStack for the "off" state. + * + * @param offItem function that returns the ItemStack for the "off" state + * @return this builder for method chaining + * @throws NullPointerException if offItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder offItem(Function offItem) { + Preconditions.checkNotNull(offItem, "offItem cannot be null"); + + this.offItem = offItem; + return this; + } + + @Contract(value = "_ -> this", mutates = "this") + public Builder onToggle(Consumer onToggle) { + Preconditions.checkNotNull(onToggle, "onToggle cannot be null"); + + this.onToggle = onToggle; + return this; + } + + /** + * Sets the sound to play when the toggle is clicked. + * + * @param sound the sound to play, or null for no sound + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder sound(@Nullable Sound sound) { + this.sound = sound; + return this; + } + + /** + * Sets the initial state of the toggle. + * + * @param state true for "on" state, false for "off" state + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder currentState(boolean state) { + this.currentState = state; + return this; + } + + /** + * Builds and returns the configured Toggle instance. + * + * @return a new Toggle with the specified configuration + */ + public Toggle build() { + return new Toggle(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java new file mode 100644 index 0000000..72f15ad --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -0,0 +1,268 @@ +package fr.kikoplugins.kikoapi.menu.component.layout; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectList; +import org.bukkit.inventory.ItemStack; +import org.checkerframework.checker.index.qual.NonNegative; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A layout component that arranges child components in a rectangular grid. + *

+ * The Grid component provides a flexible container for organizing menu components + * with optional border and fill items. Components are positioned within the grid + * using slot indices or x/y coordinates. The grid handles rendering priority as: + * slot components → border → fill. + */ +@NullMarked +public class Grid extends MenuComponent { + private final ObjectList slotComponents; + + @Nullable private final ItemStack border; + @Nullable private final ItemStack fill; + + /** + * Constructs a new Grid with the specified configuration. + * + * @param builder the builder containing the builder configuration + */ + private Grid(Builder builder) { + super(builder); + this.slotComponents = new ObjectArrayList<>(builder.slotComponents); + + this.border = builder.border; + this.fill = builder.fill; + } + + /** + * Creates a new Grid builder instance. + * + * @return a new Grid.Builder for constructing grids + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Called when this grid is added to a menu. + *

+ * Propagates the onAdd event to all child components. + * + * @param context the menu context + */ + @Override + public void onAdd(MenuContext context) { + this.slotComponents.forEach(component -> { + component.onAdd(context); + + String addedID = component.getID(); + if (addedID != null) + context.getMenu().registerComponentID(addedID, component); + }); + } + + /** + * Called when this grid is removed from a menu. + *

+ * Cleans up all child components and unregisters their IDs from the menu. + * + * @param context the menu context + */ + @Override + public void onRemove(MenuContext context) { + this.slotComponents.forEach(component -> { + component.onRemove(context); + + String removedID = component.getID(); + if (removedID != null) + context.getMenu().unregisterComponentID(removedID); + }); + } + + /** + * Handles click events within this grid. + *

+ * Delegates click events to the appropriate child component based on + * which slots the component occupies. Only processes clicks if the grid + * is interactable. + * + * @param event the inventory click event + * @param context the menu context + */ + @Override + public void onClick(KikoInventoryClickEvent event, MenuContext context) { + if (!this.isInteractable()) + return; + + for (MenuComponent component : this.slotComponents) { + if (component.getSlots(context).contains(event.getSlot())) { + component.onClick(event, context); + break; + } + } + } + + /** + * Returns the items to be displayed in this grid. + *

+ * Rendering priority: slot components → border → fill. + * Child components take precedence, followed by border decoration, + * and finally fill items for any remaining empty slots. + * + * @param context the menu context + * @return a map from slot indices to ItemStacks + */ + @Override + public Int2ObjectMap getItems(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + + for (MenuComponent slotComponent : this.slotComponents) + items.putAll(slotComponent.getItems(context)); + + if (this.border == null && this.fill == null) + return items; + + for (int y = 0; y < this.height; y++) { + for (int x = 0; x < this.width; x++) { + int slot = toSlot(x + this.getX(), y + this.getY()); + if (items.containsKey(slot)) + continue; + + if (this.border != null && this.isBorder(x + this.getX(), y + this.getY())) + items.put(slot, this.border); + else if (this.fill != null) + items.put(slot, this.fill); + } + } + + return items; + } + + /** + * Determines if the specified coordinates represent a border position. + * + * @param x the absolute x-coordinate + * @param y the absolute y-coordinate + * @return true if the position is on the grid border, false otherwise + */ + private boolean isBorder(int x, int y) { + return x == this.getX() + || x == this.getX() + this.width - 1 + || y == this.getY() + || y == this.getY() + this.height - 1; + } + + /** + * Builder class for constructing Grid instances with a fluent interface. + */ + public static class Builder extends MenuComponent.Builder { + private final ObjectList slotComponents = new ObjectArrayList<>(); + @Nullable + private ItemStack border; + @Nullable + private ItemStack fill; + + /** + * Adds a component to the grid at the specified slot index. + * + * @param slot the slot index where the component should be placed + * @param component the component to add + * @return this builder for method chaining + * @throws IllegalArgumentException if slot is negative, component is null, + * or component doesn't fit within grid bounds + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder add(@NonNegative int slot, MenuComponent component) { + Preconditions.checkArgument(slot >= 0, "slot cannot be negative: %s", slot); + Preconditions.checkNotNull(component, "component cannot be null"); + + component.setPosition(toX(slot), toY(slot)); + + // Check that the component fits inside the grid + int compX = component.getX(); + int compY = component.getY(); + int compWidth = component.getWidth(); + int compHeight = component.getHeight(); + + Preconditions.checkArgument( + compX >= 0 && compY >= 0 && + compX + compWidth <= this.width && + compY + compHeight <= this.height, + "MenuComponent %s does not fit inside the grid of size %sx%s at position (%s, %s) with size %sx%s. (Have you set the grid size before adding components ?)", + component.getClass().getSimpleName(), + this.width, this.height, + compX, compY, + compWidth, compHeight + ); + + slotComponents.add(component); + return this; + } + + /** + * Adds a component to the grid at the specified x/y coordinates. + * + * @param x the x-coordinate (0-based) + * @param y the y-coordinate (0-based) + * @param component the component to add + * @return this builder for method chaining + * @throws IllegalArgumentException if coordinates are negative or component is null + */ + @Contract(value = "_, _, _ -> this", mutates = "this") + public Builder add(@NonNegative int x, @NonNegative int y, MenuComponent component) { + Preconditions.checkArgument(x >= 0, "x cannot be negative: %s", x); + Preconditions.checkArgument(y >= 0, "y cannot be negative: %s", y); + Preconditions.checkNotNull(component, "component cannot be null"); + + return add(y * this.width + x, component); + } + + /** + * Sets the border ItemStack for this grid. + * + * @param border the ItemStack to use for border decoration + * @return this builder for method chaining + * @throws NullPointerException if border is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder border(ItemStack border) { + Preconditions.checkNotNull(border, "border cannot be null"); + + this.border = border; + return this; + } + + /** + * Sets the fill ItemStack for this grid. + * + * @param fill the ItemStack to use for empty space filling + * @return this builder for method chaining + * @throws NullPointerException if fill is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder fill(ItemStack fill) { + Preconditions.checkNotNull(fill, "fill cannot be null"); + + this.fill = fill; + return this; + } + + /** + * Builds and returns the configured Grid instance. + * + * @return a new Grid with the specified configuration + */ + public Grid build() { + return new Grid(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java new file mode 100644 index 0000000..8aa532c --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java @@ -0,0 +1,145 @@ +package fr.kikoplugins.kikoapi.menu.component.premade; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.display.Icon; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.component.layout.Grid; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Consumer; + +/** + * A pre-made menu for displaying confirmation dialogs with Yes/No options. + *

+ * This menu presents a 3-row interface with customizable yes and no buttons, + * an optional explanation item, and configurable click handlers for each option. + * The layout positions the no button on the left (slot 11), yes button on the right (slot 15), + * and the optional explanation item in the center (slot 13). + */ +@NullMarked +public class ConfirmationMenu extends Menu { + private final Component title; + private final ItemStack yesItem, noItem; + @Nullable + private final ItemStack explanationItem; + private final Consumer yesConsumer, noConsumer; + + /** + * Constructs a new ConfirmationMenu with the specified parameters. + * + * @param player the player who will view this confirmation menu + * @param title the title component displayed at the top of the menu + * @param yesItem the ItemStack to display for the "yes" button + * @param noItem the ItemStack to display for the "no" button + * @param explanationItem optional ItemStack to display as an explanation (can be null) + * @param yesConsumer the action to perform when the yes button is clicked + * @param noConsumer the action to perform when the no button is clicked + * @throws NullPointerException if any required parameter is null + */ + public ConfirmationMenu( + Player player, + Component title, + ItemStack yesItem, ItemStack noItem, + @Nullable ItemStack explanationItem, + Consumer yesConsumer, Consumer noConsumer + ) { + super(player); + + Preconditions.checkNotNull(title, "title cannot be null"); + Preconditions.checkNotNull(yesItem, "yesItem cannot be null"); + Preconditions.checkNotNull(noItem, "noItem cannot be null"); + Preconditions.checkNotNull(yesConsumer, "yesConsumer cannot be null"); + Preconditions.checkNotNull(noConsumer, "noConsumer cannot be null"); + + this.title = title; + + this.yesItem = yesItem; + this.noItem = noItem; + this.explanationItem = explanationItem; + + this.yesConsumer = yesConsumer; + this.noConsumer = noConsumer; + } + + /** + * Returns the title component for this confirmation menu. + * + * @return the title component + */ + @Override + protected Component getTitle() { + return this.title; + } + + /** + * Creates and returns the root component for this confirmation menu. + *

+ * The root component is a 9x3 grid containing: + * - No button at position 11 (left side) + * - Yes button at position 15 (right side) + * - Optional explanation icon at position 13 (center) + * + * @param context the menu context + * @return the root grid component containing all menu elements + */ + @Override + protected MenuComponent getRoot(MenuContext context) { + Grid.Builder builder = Grid.create() + .size(9, 3) + .add(11, noButton()) + .add(15, yesButton()); + + if (this.explanationItem != null) + builder.add(13, explanationIcon()); + + return builder.build(); + } + + /** + * Creates the yes button component. + * + * @return a button component configured with the yes item and click handler + */ + private Button yesButton() { + return Button.create() + .item(this.yesItem) + .onClick(this.yesConsumer) + .build(); + } + + /** + * Creates the no button component. + * + * @return a button component configured with the no item and click handler + */ + private Button noButton() { + return Button.create() + .item(this.noItem) + .onClick(this.noConsumer) + .build(); + } + + /** + * Creates the explanation icon component. + * + * @return an icon component displaying the explanation item + */ + private Icon explanationIcon() { + return Icon.create() + .item(this.explanationItem) + .build(); + } + + @Override + protected boolean canGoBackToThisMenu() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java new file mode 100644 index 0000000..1ca788e --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java @@ -0,0 +1,89 @@ +package fr.kikoplugins.kikoapi.menu.event; + +import com.google.common.base.Preconditions; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.utils.ItemBuilder; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Consumer; + +/** + * A specialized inventory click event for Kiko menu interactions. + *

+ * This class extends InventoryClickEvent to provide additional functionality + * specific to menu components, including context access and convenient + * item modification methods. + */ +@NullMarked +public class KikoInventoryClickEvent extends InventoryClickEvent { + private final MenuContext context; + + /** + * Creates a new KikoInventoryClickEvent from an existing InventoryClickEvent. + * + * @param event the original InventoryClickEvent to wrap + * @param context the menu context associated with this event + * @throws NullPointerException if context is null + */ + @SuppressWarnings("UnstableApiUsage") + public KikoInventoryClickEvent(InventoryClickEvent event, MenuContext context) { + super(event.getView(), event.getSlotType(), event.getSlot(), event.getClick(), event.getAction(), event.getHotbarButton()); + + Preconditions.checkNotNull(context, "context cannot be null"); + + this.context = context; + } + + /** + * Changes the item in the clicked slot to the specified ItemStack. + * + * @param newItem the new ItemStack to set, or null to clear the slot + */ + public void changeItem(@Nullable ItemStack newItem) { + this.setCurrentItem(newItem); + } + + /** + * Modifies the item in the clicked slot using the provided ItemBuilder modifier. + *

+ * If the current item is null, this method does nothing. Otherwise, it creates + * an ItemBuilder from the current item, applies the modifier, and sets the + * resulting item back to the slot. + * + * @param modifier a function that modifies the ItemBuilder + * @throws NullPointerException if modifier is null + */ + public void changeItem(Consumer modifier) { + Preconditions.checkNotNull(modifier, "modifier cannot be null"); + + ItemStack item = this.getCurrentItem(); + if (item == null) + return; + + ItemBuilder builder = ItemBuilder.of(item); + modifier.accept(builder); + this.setCurrentItem(builder.build()); + } + + /** + * Returns the player who clicked in the inventory. + * + * @return the player who performed the click action + */ + public Player getPlayer() { + return (Player) getWhoClicked(); + } + + /** + * Returns the menu context associated with this event. + * + * @return the menu context + */ + public MenuContext getContext() { + return this.context; + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java new file mode 100644 index 0000000..63c8f96 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java @@ -0,0 +1,77 @@ +package fr.kikoplugins.kikoapi.menu.listeners; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +import java.util.EnumSet; + +public class MenuListener implements Listener { + // These actions could lead to duping so that's a no no + private static final EnumSet DISALLOWED_ACTIONS = EnumSet.of( + InventoryAction.COLLECT_TO_CURSOR, + InventoryAction.MOVE_TO_OTHER_INVENTORY + ); + + @EventHandler(ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + Inventory inventory = event.getClickedInventory(); + if (inventory == null) + return; + + // Remove the ability for players to shift-click items in the menus + // While being able to move their items in their inventory + // getInventory will always be the top inventory + InventoryHolder topHolder = event.getInventory().getHolder(false); + if (topHolder instanceof Menu && DISALLOWED_ACTIONS.contains(event.getAction())) + event.setCancelled(true); + + // Check if a player click on a menu + InventoryHolder holder = inventory.getHolder(false); + if (!(holder instanceof Menu menu)) + return; + + event.setCancelled(true); + + // Check if the player click on an item in the menu + // If the player click on an item that means it's a component + if (event.getCurrentItem() == null) + return; + + KikoInventoryClickEvent clickEvent = new KikoInventoryClickEvent(event, menu.getContext()); + menu.handleClick(clickEvent); + } + + // That disables all the way of dragging items when you have a menu open + // I haven't found a simple & efficient way to block only dragging inside the menu + @EventHandler(ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + Inventory inventory = event.getInventory(); + InventoryHolder holder = inventory.getHolder(false); + if (!(holder instanceof Menu)) + return; + + event.setCancelled(true); + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + InventoryCloseEvent.Reason reason = event.getReason(); + if (reason == InventoryCloseEvent.Reason.PLUGIN) + return; + + Inventory inventory = event.getInventory(); + InventoryHolder holder = inventory.getHolder(false); + if (!(holder instanceof Menu menu)) + return; + + menu.close(reason); + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java new file mode 100644 index 0000000..e624f8c --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -0,0 +1,266 @@ +package fr.kikoplugins.kikoapi.menu.test; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.display.Icon; +import fr.kikoplugins.kikoapi.menu.component.display.ProgressBar; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.component.interactive.DoubleDropButton; +import fr.kikoplugins.kikoapi.menu.component.interactive.Selector; +import fr.kikoplugins.kikoapi.menu.component.interactive.Toggle; +import fr.kikoplugins.kikoapi.menu.component.layout.Grid; +import fr.kikoplugins.kikoapi.utils.ColorUtils; +import fr.kikoplugins.kikoapi.utils.Direction; +import fr.kikoplugins.kikoapi.utils.ItemBuilder; +import it.unimi.dsi.fastutil.objects.ObjectList; +import net.kyori.adventure.text.Component; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +/** + * A comprehensive test menu demonstrating various menu component functionalities. + *

+ * This menu serves as a showcase for the different types of components available + * in the menu system, including buttons, toggles, selectors, progress bars, and more. + * It demonstrates both static and dynamic content, animations, and various interaction types. + */ +@NullMarked +public class BasicTestMenu extends Menu { + + /** + * Constructs a new TestMenu for the specified player. + * + * @param player the player who will view this menu + */ + public BasicTestMenu(Player player) { + super(player); + } + + /** + * Creates a simple button with basic click and drop functionality. + * + * @return a button component that responds to clicks and drops + */ + private static Button simpleButton() { + return Button.create() + .item(ItemStack.of(Material.APPLE)) + .onClick(click -> { + click.getPlayer().sendRichMessage("You clicked the apple!"); + }) + .onDrop(click -> { + click.getPlayer().sendRichMessage("Newton"); + click.getPlayer().closeInventory(); + }) + .onClick(ClickType.SWAP_OFFHAND, click -> { + click.getPlayer().sendRichMessage("Secret Hehe :3"); + }) + .build(); + } + + /** + * Creates an animated button that cycles through different colored wool blocks. + * + * @return a 2x2 animated button with rainbow color progression + */ + private static Button animatedButton() { + return Button.create() + .size(2, 2) + .animationFrames(context -> + ObjectList.of(ItemStack.of(Material.SKELETON_SKULL), + ItemStack.of(Material.ORANGE_WOOL), + ItemStack.of(Material.END_ROD), + ItemStack.of(Material.LIME_WOOL), + ItemStack.of(Material.BLUE_WOOL), + ItemStack.of(Material.PURPLE_WOOL)) + ) + .animationInterval(1) + .onClick(click -> { + click.getPlayer().sendRichMessage("You clicked the animated button!"); + }) + .build(); + } + + /** + * Creates a dynamic button that displays the current seconds. + * + * @return a button that updates every second to show current seconds + */ + private static Button dynamicButton() { + return Button.create() + .item(context -> { + int seconds = (int) (System.currentTimeMillis() / 1000 % 60); + return ItemBuilder.of(Material.OAK_SIGN).name( + Component.text("Seconds: " + seconds) + ).build(); + }) + .updateInterval(20) + .onClick(click -> { + click.getPlayer().sendRichMessage("This button shows the current seconds!"); + }) + .build(); + } + + /** + * Creates a dynamic button that displays the player's current coordinates. + * + * @return a button that updates every tick to show player coordinates + */ + private static Button coordinatesDynamicButton() { + return Button.create() + .item(context -> { + Player player = context.getPlayer(); + double x = player.getLocation().getX(); + double y = player.getLocation().getY(); + double z = player.getLocation().getZ(); + return ItemBuilder.of(Material.COMPASS).name( + Component.text(String.format("Coordinates: (%.1f, %.1f, %.1f)", x, y, z)) + ).build(); + }) + .updateInterval(1) + .onClick(click -> { + click.getPlayer().sendRichMessage("This button shows your current coordinates!"); + }) + .build(); + } + + /** + * Creates a simple toggle switch with on/off states. + * + * @return a toggle component using lime and red dye items + */ + private static Toggle toggle() { + return Toggle.create() + .onItem(ItemStack.of(Material.LIME_DYE)) + .offItem(ItemStack.of(Material.RED_DYE)) + .onToggle(event -> { + event.clickEvent().getPlayer().sendRichMessage("" + event.newState()); + }) + .build(); + } + + /** + * Creates a static display icon with no interaction. + * + * @return an icon component displaying a bedrock item + */ + private static Icon icon() { + return Icon.create() + .item(ItemBuilder.of(Material.BEDROCK) + .name(Component.text("Just a useless item")) + .build()) + .build(); + } + + /** + * Creates a GameMode selector that allows cycling through game modes. + * + * @return a selector component that changes the player's game mode + */ + private static Selector selector() { + return Selector.create() + .addOption(ItemBuilder.of(Material.WOODEN_SWORD).name(Component.text("Survival")).build(), GameMode.SURVIVAL) + .addOption(ItemBuilder.of(Material.COMPASS).name(Component.text("Adventure")).build(), GameMode.ADVENTURE) + .addOption(ItemBuilder.of(Material.DIAMOND_BLOCK).name(Component.text("Creative")).build(), GameMode.CREATIVE) + .defaultOption(context -> context.getPlayer().getGameMode()) + .onSelectionChange(event -> event.context().getPlayer().setGameMode(event.newValue())) + .build(); + } + + /** + * Creates a double-drop button that requires two quick drop actions to trigger. + * + * @return a double-drop button that responds to rapid drop actions + */ + private static DoubleDropButton doubleDropButton() { + return DoubleDropButton.create() + .item(ItemBuilder.of(Material.CHEST).name(Component.text("Just a chest")).build()) + .dropItem(ItemBuilder.of(Material.ALLAY_SPAWN_EGG).name(Component.text("Are you sure ?")).build()) + .onDoubleDrop(event -> { + Player player = event.getPlayer(); + player.sendRichMessage("You have double-dropped the chest button!"); + }) + .build(); + } + + /** + * Creates a horizontal progress bar showing 75% completion. + * + * @return a 4x2 progress bar extending to the right + */ + private static ProgressBar rightProgressBar() { + return ProgressBar.create() + .doneItem(ItemStack.of(Material.LIME_CONCRETE)) + .currentItem(ItemStack.of(Material.ORANGE_CONCRETE)) + .notDoneItem(ItemStack.of(Material.RED_CONCRETE)) + .direction(Direction.Default.RIGHT) + .percentage(0.75) + .size(4, 2) + .build(); + } + + /** + * Creates a vertical progress bar showing 100% completion. + * + * @return a 1x5 progress bar extending downward + */ + private static ProgressBar downProgressBar() { + return ProgressBar.create() + .doneItem(ItemStack.of(Material.LIME_CONCRETE)) + .currentItem(ItemStack.of(Material.ORANGE_CONCRETE)) + .notDoneItem(ItemStack.of(Material.RED_CONCRETE)) + .direction(Direction.Default.DOWN) + .percentage(1) + .size(1, 5) + .build(); + } + + /** + * Returns the title component for this test menu. + * + * @return a colorized title component + */ + @Override + protected Component getTitle() { + return Component.text("Test Menu Hehe :3", ColorUtils.getPrimaryColor()); + } + + /** + * Creates and returns the root component for this test menu. + *

+ * The menu layout includes various component demonstrations: + * - Simple button with click and drop handlers (slot 0) + * - Animated button with color-changing frames (slot 2, 2x2 size) + * - Dynamic button showing current seconds (slot 8) + * - Coordinates display button (slot 13) + * - Toggle switch (slot 15) + * - Static icon (slot 16) + * - GameMode selector (slot 18) + * - Double-drop button (slot 20) + * - Horizontal progress bar (slot 21, 4x2 size) + * - Vertical progress bar (slot 17, 1x5 size) + * + * @param context the menu context + * @return the root grid component containing all test components + */ + @Override + protected MenuComponent getRoot(MenuContext context) { + return Grid.create() + .size(9, 6) + .add(0, simpleButton()) + .add(2, animatedButton()) + .add(8, dynamicButton()) + .add(13, coordinatesDynamicButton()) + .add(15, toggle()) + .add(16, icon()) + .add(18, selector()) + .add(20, doubleDropButton()) + .add(21, rightProgressBar()) + .add(17, downProgressBar()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java new file mode 100644 index 0000000..6a58c5e --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java @@ -0,0 +1,197 @@ +package fr.kikoplugins.kikoapi.menu.test; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.display.Icon; +import fr.kikoplugins.kikoapi.menu.component.display.ProgressBar; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.component.layout.Grid; +import fr.kikoplugins.kikoapi.utils.ColorUtils; +import fr.kikoplugins.kikoapi.utils.Direction; +import fr.kikoplugins.kikoapi.utils.ItemBuilder; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +/** + * A test menu demonstrating dynamic component updates. + *

+ * This menu showcases the new ability to modify component properties + * after creation and update them in real-time. + */ +@NullMarked +public class DynamicTestMenu extends Menu { + private static final String PROGRESS_ID = "progress"; + private static final String STATUS_ID = "status"; + private int currentProgress = 0; + + /** + * Constructs a new DynamicTestMenu for the specified player. + * + * @param player the player who will view this menu + */ + public DynamicTestMenu(Player player) { + super(player); + } + + /** + * Returns the title component for this test menu. + * + * @return a colorized title component + */ + @Override + protected Component getTitle() { + return Component.text("Dynamic Component Test", ColorUtils.getPrimaryColor()); + } + + /** + * Builds the root component layout for this test menu. + *

+ * The layout includes a progress bar, status icon, and buttons to + * increment, decrement, and reset progress. + * + * @param context the menu context + * @return the root component layout + */ + @Override + protected MenuComponent getRoot(MenuContext context) { + ProgressBar progressBar = ProgressBar.create() + .id(PROGRESS_ID) + .doneItem(ItemStack.of(Material.LIME_CONCRETE)) + .currentItem(ItemStack.of(Material.ORANGE_CONCRETE)) + .notDoneItem(ItemStack.of(Material.RED_CONCRETE)) + .direction(Direction.Default.RIGHT) + .percentage(this.currentProgress / 100D) + .size(5, 1) + .build(); + + Icon statusIcon = Icon.create() + .id(STATUS_ID) + .item(statusItem()) + .build(); + + Button decrementButton = Button.create() + .item(ctx -> { + if (this.currentProgress <= 0) { + return ItemBuilder.of(Material.GRAY_DYE) + .name(Component.text("Min Progress Reached")) + .build(); + } + + return ItemBuilder.of(Material.RED_DYE) + .name(Component.text("Decrement (-10%)")) + .build(); + }) + .onClick(click -> { + if (this.currentProgress <= 0) + return; + + this.currentProgress -= 10; + updateComponents(); + click.getPlayer().sendMessage("Progress: " + currentProgress + "%"); + }) + .build(); + + Button incrementButton = Button.create() + .item(ctx -> { + if (this.currentProgress >= 100) { + return ItemBuilder.of(Material.GRAY_DYE) + .name(Component.text("Max Progress Reached")) + .build(); + } + + return ItemBuilder.of(Material.LIME_DYE) + .name(Component.text("Increment (+10%)")) + .build(); + }) + .onClick(click -> { + if (this.currentProgress >= 100) + return; + + this.currentProgress += 10; + updateComponents(); + click.getPlayer().sendMessage("Progress: " + currentProgress + "%"); + }) + .build(); + + Button resetButton = Button.create() + .item(ItemBuilder.of(Material.BARRIER) + .name(Component.text("Reset")) + .build()) + .onClick(click -> { + this.currentProgress = 0; + updateComponents(); + click.getPlayer().sendMessage("Progress reset to 0%"); + }) + .build(); + + return Grid.create() + .size(9, 6) + .add(11, progressBar) + .add(31, statusIcon) + .add(39, decrementButton) + .add(41, incrementButton) + .add(49, resetButton) + .build(); + } + + /** + * Updates all dynamic components based on the current progress. + *

+ * This demonstrates the new dynamic update capability: + * - Updates the progress bar percentage + * - Changes the status icon based on progress level + */ + private void updateComponents() { + ProgressBar progressBar = (ProgressBar) this.getComponentByID(PROGRESS_ID); + Icon statusIcon = (Icon) this.getComponentByID(STATUS_ID); + + progressBar.percentage(this.currentProgress()); + + statusIcon.item(this.statusItem()); + + progressBar.render(this.context); + statusIcon.render(this.context); + } + + /** + * Gets the appropriate status icon based on current progress. + * + * @return an ItemStack representing the current status + */ + private ItemStack statusItem() { + Material material; + String status; + + if (this.currentProgress >= 100) { + material = Material.DIAMOND; + status = "Complete!"; + } else if (this.currentProgress >= 75) { + material = Material.EMERALD; + status = "Almost there!"; + } else if (this.currentProgress >= 50) { + material = Material.GOLD_INGOT; + status = "Halfway!"; + } else if (this.currentProgress >= 25) { + material = Material.IRON_INGOT; + status = "Making progress..."; + } else if (this.currentProgress > 0) { + material = Material.COPPER_INGOT; + status = "Just started"; + } else { + material = Material.COAL; + status = "Not started"; + } + + return ItemBuilder.of(material) + .name(Component.text(status)) + .build(); + } + + private double currentProgress() { + return (double) currentProgress / 100; + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java new file mode 100644 index 0000000..0456cf3 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java @@ -0,0 +1,109 @@ +package fr.kikoplugins.kikoapi.menu.test; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.container.Paginator; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.component.layout.Grid; +import fr.kikoplugins.kikoapi.utils.ColorUtils; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectList; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +import java.util.Arrays; + +/** + * A test menu demonstrating paginated content functionality. + *

+ * This menu displays a paginated grid of all available Material items, + * filtered to exclude legacy items, non-items (like piston heads), + * disabled experimental features, and air blocks. The pagination controls + * are positioned at the bottom of the menu for easy navigation. + */ +@NullMarked +public class PaginatedTestMenu extends Menu { + + /** + * Constructs a new PaginatedTestMenu for the specified player. + * + * @param player the player who will view this menu + */ + public PaginatedTestMenu(Player player) { + super(player); + } + + /** + * Returns the title component for this test menu. + * + * @return a colorized title component + */ + @Override + protected Component getTitle() { + return Component.text("Paginated Test Menu Hehe :3", ColorUtils.getPrimaryColor()); + } + + /** + * Creates and returns the root component for this paginated test menu. + *

+ * The menu structure consists of: + * - A 7x3 paginator area starting at position (10) containing filtered Material buttons + * - Navigation controls at the bottom row: + * - First page button at slot 45 + * - Back button at slot 46 + * - Next button at slot 52 + * - Last page button at slot 53 + * + * @param context the menu context + * @return the root grid component containing the paginator and navigation controls + */ + @Override + protected MenuComponent getRoot(MenuContext context) { + Paginator paginator = Paginator.create() + .size(7, 3) + .firstPageItem(ItemStack.of(Material.SPECTRAL_ARROW)) + .lastPageItem(ItemStack.of(Material.SPECTRAL_ARROW)) + .offBackItem(ItemStack.of(Material.RED_DYE)) + .offNextItem(ItemStack.of(Material.RED_DYE)) + .offFirstPageItem(ItemStack.of(Material.ORANGE_DYE)) + .offLastPageItem(ItemStack.of(Material.ORANGE_DYE)) + .build(); + + World world = this.getPlayer().getWorld(); + ObjectList materials = Arrays.stream(Material.values()) + .filter(material -> !material.isLegacy()) + .filter(Material::isItem) // Remove things like Piston Head + .filter(material -> world.isEnabled(material.asItemType())) // Remove disabled experimental features + .filter(material -> !material.isAir()) + .map(ItemStack::of) + .map(itemStack -> { + return Button.create() + .item(itemStack) + .onClick(event -> { + event.getPlayer().sendMessage(Component.translatable(event.getCurrentItem().translationKey())); + + MenuContext ctx = event.getContext(); + paginator.remove(ctx, event.getSlot()); + paginator.render(ctx); + }) + .build(); + }) + .collect(ObjectArrayList.toList()); + + paginator.addAll(context, materials); + + return Grid.create() + .size(9, 6) + .add(45, paginator.getFirstPageButton()) + .add(46, paginator.getBackButton()) + .add(52, paginator.getNextButton()) + .add(53, paginator.getLastPageButton()) + .add(10, paginator) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java new file mode 100644 index 0000000..3f2f3a4 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java @@ -0,0 +1,77 @@ +package fr.kikoplugins.kikoapi.menu.test; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.MenuContext; +import fr.kikoplugins.kikoapi.menu.component.MenuComponent; +import fr.kikoplugins.kikoapi.menu.component.display.Icon; +import fr.kikoplugins.kikoapi.menu.component.interactive.Button; +import fr.kikoplugins.kikoapi.menu.component.layout.Grid; +import fr.kikoplugins.kikoapi.utils.ColorUtils; +import fr.kikoplugins.kikoapi.utils.ItemBuilder; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * Example menu using the previousMenus system. + */ +@NullMarked +public class PreviousTestMenu extends Menu { + + public PreviousTestMenu(Player player) { + super(player); + } + + public PreviousTestMenu(Player player, MenuContext context) { + super(player, context); + } + + private static Button previousMenuButton() { + return Button.create() + .item(ItemBuilder.of(Material.ARROW) + .name(Component.text("Go to Previous Menu")) + .build()) + .onClick(event -> { + Menu previous = event.getContext().getPreviousMenu(); + if (previous == null) { + event.getPlayer().sendMessage(Component.text("No previous menu found!", NamedTextColor.RED)); + return; + } + + previous.open(); + }) + .build(); + } + + private static Button nextMenuButton() { + return Button.create() + .item(ItemBuilder.of(Material.ARROW) + .name(Component.text("Go to Next Menu")) + .build()) + .onClick(event -> { + new PreviousTestMenu(event.getPlayer(), event.getContext()).open(); + }) + .build(); + } + + @Override + protected Component getTitle() { + return Component.text("Menu ID: " + System.identityHashCode(this), ColorUtils.getPrimaryColor()); + } + + @Override + protected MenuComponent getRoot(MenuContext context) { + return Grid.create() + .size(9, 3) + .add(0, previousMenuButton()) + .add(4, Icon.create() + .item(ItemBuilder.of(Material.BOOK) + .name(Component.text("Current Menu ID: " + System.identityHashCode(this))) + .build()) + .build()) + .add(8, nextMenuButton()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java b/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java index 48ffb77..a398f56 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java +++ b/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java @@ -70,7 +70,7 @@ public UpdateChecker(JavaPlugin plugin, String modrinthID) { private void startTask() { Task.asyncRepeat(task -> { - this.latestVersion = this.latestVersion(); + this.latestVersion = this.getLatestVersion(); this.noNewVersion = this.latestVersion == null || StringUtils.compareSemVer(this.currentVersion, this.latestVersion) >= 0; if (this.noNewVersion) @@ -92,7 +92,7 @@ private void startTask() { } @Nullable - private String latestVersion() { + private String getLatestVersion() { try { HttpRequest request = HttpRequest.newBuilder() .GET() diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java new file mode 100644 index 0000000..9de26dd --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java @@ -0,0 +1,34 @@ +package fr.kikoplugins.kikoapi.utils; + +import net.kyori.adventure.text.format.TextColor; +import org.jspecify.annotations.NullMarked; + +/** + * Utility class for managing text colors. + */ +// Hex values are hardcoded +@SuppressWarnings("DataFlowIssue") +@NullMarked +public class ColorUtils { + private ColorUtils() { + throw new IllegalStateException("Utility class"); + } + + /** + * Gets the primary color used in the KikoAPI. + * + * @return The primary TextColor. + */ + public static TextColor getPrimaryColor() { + return TextColor.fromHexString("#FC67FA"); + } + + /** + * Gets the secondary color used in the KikoAPI. + * + * @return The secondary TextColor. + */ + public static TextColor getSecondaryColor() { + return TextColor.fromHexString("#F4C4F3"); + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/Direction.java b/src/main/java/fr/kikoplugins/kikoapi/utils/Direction.java new file mode 100644 index 0000000..afed092 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/Direction.java @@ -0,0 +1,36 @@ +package fr.kikoplugins.kikoapi.utils; + +public class Direction { + private Direction() { + throw new IllegalStateException("Utility class"); + } + + public enum Default { + UP, + LEFT, + RIGHT, + DOWN + } + + public enum Cardinal { + NORTH(0, -1), + WEST(-1, 0), + EAST(1, 0), + SOUTH(0, 1); + + private final int x, z; + + Cardinal(int x, int z) { + this.x = x; + this.z = z; + } + + public int x() { + return x; + } + + public int z() { + return z; + } + } +} diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java new file mode 100644 index 0000000..fbdd1ef --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java @@ -0,0 +1,1496 @@ +package fr.kikoplugins.kikoapi.utils; + +import com.destroystokyo.paper.profile.ProfileProperty; +import com.google.common.base.Preconditions; +import io.papermc.paper.datacomponent.DataComponentType; +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.*; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.block.banner.Pattern; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.checkerframework.checker.index.qual.NonNegative; +import org.checkerframework.checker.index.qual.Positive; +import org.checkerframework.common.value.qual.IntRange; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.net.URL; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Utility builder for creating and modifying ItemStack instances using Paper's + * DataComponent APIs (item data components, attributes, enchantments, lore, + * profiles, etc.). The builder wraps an ItemStack and provides fluent methods + * to set/get many of the commonly used data component properties. + * + *

Usage example: + *

+ * ItemStack item = ItemBuilder.of(Material.DIAMOND_SWORD)
+ *     .name(Component.text("Epic Blade"))
+ *     .addEnchantment(Enchantment.SHARPNESS, 5)
+ *     .unbreakable(true)
+ *     .build();
+ * 
+ *

+ * Notes: + * - Many methods use Paper's {@link DataComponentTypes} and will only work on + * servers that support Paper's DataComponent API. + * - Methods throw IllegalArgumentException for invalid inputs (e.g. air + * materials, invalid amounts). The check against air materials exists because + * air does not have an {@link ItemMeta} and many item meta/data operations + * would be invalid for air items. + */ +@SuppressWarnings({"UnstableApiUsage", "ClassCanBeRecord", "unused", "UnusedReturnValue"}) +public class ItemBuilder { + private final ItemStack itemStack; + + private ItemBuilder(ItemStack itemStack) { + this.itemStack = itemStack; + } + + /** + * Create a new ItemBuilder for the given material. + * + * @param material the non-air Material for the item + * @return a new ItemBuilder wrapping a newly created ItemStack + * @throws IllegalArgumentException if the material is air (air doesn't + * have an {@link ItemMeta}) + */ + @Contract(value = "_ -> new", pure = true) + public static ItemBuilder of(Material material) { + Preconditions.checkNotNull(material, "material cannot be null"); + Preconditions.checkArgument(!material.isAir(), "material cannot be air"); + + return ItemBuilder.of(material, 1); + } + + /** + * Create a new ItemBuilder wrapping an existing ItemStack. + * + * @param itemStack the ItemStack to wrap (type must not be air) + * @return a new ItemBuilder + * @throws IllegalArgumentException if the item's material is air (air + * doesn't have an {@link ItemMeta}) + */ + @Contract(value = "_ -> new", pure = true) + public static ItemBuilder of(ItemStack itemStack) { + Preconditions.checkNotNull(itemStack, "itemStack cannot be null"); + Preconditions.checkArgument(!itemStack.getType().isAir(), "itemStack cannot be air"); + Preconditions.checkArgument(itemStack.getAmount() >= 1, "itemStack amount cannot be less than 1: %s", itemStack.getAmount()); + + return new ItemBuilder(itemStack); + } + + /** + * Create a new ItemBuilder for the given material and amount. + * + * @param material the non-air Material for the item + * @param amount the amount to set on the created ItemStack + * @return a new ItemBuilder + * @throws IllegalArgumentException if the material is air (air doesn't + * have an {@link ItemMeta}) + */ + @Contract(value = "_, _ -> new", pure = true) + public static ItemBuilder of(Material material, @Positive int amount) { + Preconditions.checkNotNull(material, "material cannot be null"); + Preconditions.checkArgument(!material.isAir(), "material cannot be air"); + Preconditions.checkArgument(amount >= 1, "amount cannot be less than 1: %s", amount); + + return new ItemBuilder(ItemStack.of(material, amount)); + } + + /** + * Return the wrapped ItemStack. + * + * @return the ItemStack instance (not a defensive copy) + */ + public ItemStack build() { + return itemStack; + } + + /** + * Return a clone of the wrapped ItemStack. + * + * @return a cloned ItemStack + */ + public ItemStack buildCopy() { + return itemStack.clone(); + } + + /** + * Return a new ItemBuilder wrapping a clone of the current ItemStack. + * + * @return a new ItemBuilder with a cloned ItemStack + */ + @Contract(value = "-> new", pure = true) + public ItemBuilder copy() { + return new ItemBuilder(itemStack.clone()); + } + + /** + * Set the amount (stack size) for this ItemStack. + * + * @param amount the desired amount (must be > 0) + * @return this builder + * @throws IllegalArgumentException if amount is out of range + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder amount(@Positive int amount) { + Preconditions.checkArgument(amount >= 1, "amount cannot be less than 1: %s", amount); + + itemStack.setAmount(amount); + return this; + } + + /** + * Get the current amount (stack size) of the item. + * + * @return the item amount + */ + @Positive + public int amount() { + return itemStack.getAmount(); + } + + /** + * Set the display name using Paper's {@link DataComponentTypes#ITEM_NAME} + * data component. + * + * @param name the component to set as the name + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder name(Component name) { + Preconditions.checkNotNull(name, "name cannot be null"); + + itemStack.setData(DataComponentTypes.ITEM_NAME, name); + return this; + } + + /** + * Get the display name set via {@link DataComponentTypes#ITEM_NAME}. + * + * @return the name component, or null if not set + */ + @Nullable + public Component name() { + return itemStack.getData(DataComponentTypes.ITEM_NAME); + } + + /** + * Set a renamable custom name using {@link DataComponentTypes#CUSTOM_NAME}. + * + *

This method sets a CUSTOM_NAME that players can remove (rename back + * to default) in an anvil — i.e. it creates a renamable name. It is also + * used for player heads; note that some head rendering logic may not be + * affected by changing the method name on certain head types, so this + * method is appropriate both when you want a renamable name and when + * working with player head items. + * + *

The provided component will have its italic decoration set to false + * only when the developer did not explicitly specify an italic value. + * This is done via + * {@link Component#decorationIfAbsent(TextDecoration, TextDecoration.State)} + * — the method does not force italic off if the developer already set it. + * + * @param name the custom name component + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder renamableName(Component name) { + Preconditions.checkNotNull(name, "name cannot be null"); + + itemStack.setData(DataComponentTypes.CUSTOM_NAME, name.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)); + return this; + } + + /** + * Get the {@link DataComponentTypes#CUSTOM_NAME} value for the item. + * + * @return the custom name component, or null if not present + */ + @Nullable + public Component renamableName() { + return itemStack.getData(DataComponentTypes.CUSTOM_NAME); + } + + /** + * Set the item model key using {@link DataComponentTypes#ITEM_MODEL}. + * + * @param itemModelKey the model key to assign + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder itemModel(Key itemModelKey) { + Preconditions.checkNotNull(itemModelKey, "itemModelKey cannot be null"); + + itemStack.setData(DataComponentTypes.ITEM_MODEL, itemModelKey); + return this; + } + + /** + * Get the item model key assigned to this item ({@link DataComponentTypes#ITEM_MODEL}). + * + * @return the model key + */ + public Key itemModel() { + return itemStack.getData(DataComponentTypes.ITEM_MODEL); + } + + /** + * Set the current durability (remaining durability) of the item. + * Uses {@link DataComponentTypes#DAMAGE} and {@link DataComponentTypes#MAX_DAMAGE}. + * + * @param durability durability value (remaining), will be converted to DAMAGE + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder durability(@NonNegative int durability) { + Preconditions.checkArgument(durability >= 0, "durability cannot be negative: %s", durability); + + itemStack.setData(DataComponentTypes.DAMAGE, maxDamage() - durability); + return this; + } + + /** + * Get the current durability (remaining) of the item. + * + * @return remaining durability + */ + @NonNegative + public int durability() { + Integer maxDamageComponent = itemStack.getData(DataComponentTypes.MAX_DAMAGE); + Integer damageComponent = itemStack.getData(DataComponentTypes.DAMAGE); + + int maxDamage = maxDamageComponent == null ? itemStack.getType().getMaxDurability() : maxDamageComponent; + int damage = damageComponent == null ? 0 : damageComponent; + + return maxDamage - damage; + } + + /** + * Directly set the raw {@link DataComponentTypes#DAMAGE} data component + * (damage taken). + * + * @param damage the damage value + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder damage(@NonNegative int damage) { + Preconditions.checkArgument(damage >= 0, "damage cannot be negative: %s", damage); + + itemStack.setData(DataComponentTypes.DAMAGE, damage); + return this; + } + + /** + * Get the raw {@link DataComponentTypes#DAMAGE} data component. + * + * @return the damage value or null if not set + */ + @Nullable + @NonNegative + public Integer damage() { + return itemStack.getData(DataComponentTypes.DAMAGE); + } + + /** + * Set the maximum durability of the item using + * {@link DataComponentTypes#MAX_DAMAGE}. + * + * @param maxDamage the maximum durability value + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder maxDamage(@Positive int maxDamage) { + Preconditions.checkArgument(maxDamage >= 1, "maxDamage must be positive: %s", maxDamage); + + itemStack.setData(DataComponentTypes.MAX_DAMAGE, maxDamage); + return this; + } + + /** + * Get the maximum durability of the item. + * + * @return the maximum durability value + */ + @Positive + public int maxDamage() { + Integer data = itemStack.getData(DataComponentTypes.MAX_DAMAGE); + return data == null ? itemStack.getType().getMaxDurability() : data; + } + + /** + * Add a single enchantment to the item while preserving existing enchantments + * using {@link DataComponentTypes#ENCHANTMENTS}. + * + * @param enchantment the enchantment to add + * @param level the level for that enchantment + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder addEnchantment(Enchantment enchantment, @IntRange(from = 1, to = 255) int level) { + Preconditions.checkNotNull(enchantment, "enchantment cannot be null"); + Preconditions.checkArgument(level >= 1 && level <= 255, "level must be between 1 and 255: %s", level); + + ItemEnchantments data = itemStack.getData(DataComponentTypes.ENCHANTMENTS); + ItemEnchantments.Builder itemEnchantments = ItemEnchantments.itemEnchantments() + .add(enchantment, level); + + if (data != null) + itemEnchantments.addAll(data.enchantments()); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments.build()); + return this; + } + + /** + * Overwrite enchantments with a single enchantment entry ({@link DataComponentTypes#ENCHANTMENTS}). + * + * @param enchantment the enchantment + * @param level the level + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder enchantment(Enchantment enchantment, @IntRange(from = 1, to = 255) int level) { + Preconditions.checkNotNull(enchantment, "enchantment cannot be null"); + Preconditions.checkArgument(level >= 1 && level <= 255, "level must be between 1 and 255: %s", level); + + ItemEnchantments itemEnchantments = ItemEnchantments.itemEnchantments() + .add(enchantment, level) + .build(); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments); + return this; + } + + /** + * Add multiple enchantments while preserving existing enchantments. + * + * @param enchantments a map of enchantments to levels + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder addEnchantments(Map enchantments) { + Preconditions.checkNotNull(enchantments, "enchantments cannot be null"); + + ItemEnchantments data = itemStack.getData(DataComponentTypes.ENCHANTMENTS); + ItemEnchantments.Builder itemEnchantments = ItemEnchantments.itemEnchantments() + .addAll(enchantments); + + if (data != null) + itemEnchantments.addAll(data.enchantments()); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments.build()); + return this; + } + + /** + * Overwrite all enchantments with the provided map. + * + * @param enchantments map of enchantments to levels + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder enchantments(Map enchantments) { + Preconditions.checkNotNull(enchantments, "enchantments cannot be null"); + + ItemEnchantments itemEnchantments = ItemEnchantments.itemEnchantments() + .addAll(enchantments) + .build(); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments); + return this; + } + + /** + * Remove a specific enchantment from the item. + * + * @param enchantment the enchantment to remove + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeEnchantment(Enchantment enchantment) { + Preconditions.checkNotNull(enchantment, "enchantment cannot be null"); + + ItemEnchantments data = itemStack.getData(DataComponentTypes.ENCHANTMENTS); + if (data == null) + return this; + + Map enchantments = new HashMap<>(data.enchantments()); + enchantments.remove(enchantment); + + ItemEnchantments itemEnchantments = ItemEnchantments.itemEnchantments() + .addAll(enchantments) + .build(); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments); + return this; + } + + /** + * Remove multiple enchantments from the item. + * + * @param enchantments the enchantments to remove + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeEnchantments(Enchantment... enchantments) { + Preconditions.checkNotNull(enchantments, "enchantments cannot be null"); + + ItemEnchantments data = itemStack.getData(DataComponentTypes.ENCHANTMENTS); + if (data == null) + return this; + + Map enchants = new HashMap<>(data.enchantments()); + Arrays.stream(enchantments).forEach(enchants::remove); + + ItemEnchantments itemEnchantments = ItemEnchantments.itemEnchantments() + .addAll(enchants) + .build(); + + itemStack.setData(DataComponentTypes.ENCHANTMENTS, itemEnchantments); + return this; + } + + /** + * Remove all enchantments from the item. + * + * @return this builder + */ + public ItemBuilder removeEnchantments() { + itemStack.setData(DataComponentTypes.ENCHANTMENTS, ItemEnchantments.itemEnchantments().build()); + return this; + } + + /** + * Get the {@link DataComponentTypes#ENCHANTMENTS} data component. + * + * @return the ItemEnchantments or null if none set + */ + @Nullable + public ItemEnchantments enchantments() { + return itemStack.getData(DataComponentTypes.ENCHANTMENTS); + } + + /** + * Get a map of enchantments currently applied to the item. + * + * @return a map of Enchantment -> level (empty map if none) + */ + public Map enchantmentsMap() { + ItemEnchantments data = itemStack.getData(DataComponentTypes.ENCHANTMENTS); + return data == null ? Collections.emptyMap() : data.enchantments(); + } + + /** + * Force or disable the enchantment glint (visual) using + * {@link DataComponentTypes#ENCHANTMENT_GLINT_OVERRIDE}. + * + * @param glowing true to force glint, false to unset + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder forceGlowing(boolean glowing) { + itemStack.setData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE, glowing); + return this; + } + + /** + * Reset the forced glowing state (remove {@link DataComponentTypes#ENCHANTMENT_GLINT_OVERRIDE}). + * + * @return this builder + */ + public ItemBuilder resetGlowing() { + itemStack.unsetData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE); + return this; + } + + /** + * Check whether the enchantment glint was forced. + * + * @return true if glint override is present + */ + public boolean forcedGlowing() { + return itemStack.hasData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE) && itemStack.getData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE); + } + + /** + * Set the head/profile data to reference an OfflinePlayer (by UUID). + * Useful for player head items; stores a {@link DataComponentTypes#PROFILE}. + * + * @param player the offline player whose UUID will be used + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder headTexture(OfflinePlayer player) { + Preconditions.checkNotNull(player, "player cannot be null"); + + ResolvableProfile resolvableProfile = ResolvableProfile.resolvableProfile() + .uuid(player.getUniqueId()) + .build(); + + itemStack.setData(DataComponentTypes.PROFILE, resolvableProfile); + return this; + } + + /** + * Set a head texture using a raw texture URL. The method encodes the + * required base64 property and creates a {@link DataComponentTypes#PROFILE} + * with a "textures" property. + * + * @param textureURL the full URL to the skin texture + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder headTexture(String textureURL) { + Preconditions.checkNotNull(textureURL, "textureURL cannot be null"); + + byte[] texturesPropertyBytes = Base64.getEncoder().encode("{\"textures\":{\"SKIN\":{\"url\":\"%s\"}}}".formatted(textureURL).getBytes()); + String texturesProperty = new String(texturesPropertyBytes); + + ResolvableProfile resolvableProfile = ResolvableProfile.resolvableProfile() + .addProperty(new ProfileProperty("textures", texturesProperty)) + .build(); + + itemStack.setData(DataComponentTypes.PROFILE, resolvableProfile); + return this; + } + + /** + * Set a head texture using a URL object. + * + * @param textureURL the texture URL + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder headTexture(URL textureURL) { + Preconditions.checkNotNull(textureURL, "textureURL cannot be null"); + + return headTexture(textureURL.toString()); + } + + /** + * Get the base64-encoded texture property used for head textures, if any. + * + * @return base64 texture string or null if not present + */ + @Nullable + public String headTextureBase64() { + ResolvableProfile resolvableProfile = itemStack.getData(DataComponentTypes.PROFILE); + if (resolvableProfile == null) + return null; + + if (resolvableProfile.properties().isEmpty()) + return null; + + Optional property = resolvableProfile.properties().stream() + .filter(prop -> prop.getName().equals("textures")) + .findFirst(); + + return property.map(ProfileProperty::getValue).orElse(null); + } + + /** + * Get the {@link DataComponentTypes#PROFILE} stored on the item (profile used for player + * heads). + * + * @return the ResolvableProfile or null if none set + */ + @Nullable + public ResolvableProfile headTextureProfile() { + return itemStack.getData(DataComponentTypes.PROFILE); + } + + /** + * Set or unset the {@link DataComponentTypes#UNBREAKABLE} flag on the item. + * + * @param unbreakable true to set unbreakable, false to unset + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder unbreakable(boolean unbreakable) { + if (unbreakable) { + itemStack.setData(DataComponentTypes.UNBREAKABLE); + } else { + itemStack.unsetData(DataComponentTypes.UNBREAKABLE); + } + + return this; + } + + /** + * Check whether the item is marked {@link DataComponentTypes#UNBREAKABLE}. + * + * @return true if unbreakable + */ + public boolean unbreakable() { + return itemStack.hasData(DataComponentTypes.UNBREAKABLE); + } + + /** + * Set lore lines for the item using {@link DataComponentTypes#LORE}. + * + *

Lines will have their italic decoration set only if the developer has + * not explicitly specified italic (see + * {@link Component#decorationIfAbsent(TextDecoration, TextDecoration.State)}). + * + * @param lore the lore components + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder lore(Component... lore) { + Preconditions.checkNotNull(lore, "lore cannot be null"); + + List loreLines = new ArrayList<>(); + Arrays.stream(lore).forEach(l -> loreLines.add(l.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE))); + + itemStack.setData(DataComponentTypes.LORE, ItemLore.lore(loreLines)); + return this; + } + + /** + * Set lore from a list of components ({@link DataComponentTypes#LORE}). + * + * @param lore the list of lore components + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder lore(List lore) { + Preconditions.checkNotNull(lore, "lore cannot be null"); + + List loreLines = new ArrayList<>(); + lore.forEach(l -> loreLines.add(l.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE))); + + itemStack.setData(DataComponentTypes.LORE, ItemLore.lore(loreLines)); + return this; + } + + /** + * Remove the {@link DataComponentTypes#LORE} data component from the item. + * + * @return this builder + */ + public ItemBuilder removeLore() { + itemStack.unsetData(DataComponentTypes.LORE); + return this; + } + + /** + * Get a specific lore line by index. + * + * @param index the line index (0-based) + * @return the component at index or null if not present + */ + @Nullable + public Component loreLine(@NonNegative int index) { + Preconditions.checkArgument(index >= 0, "index cannot be negative: %s", index); + + ItemLore data = itemStack.getData(DataComponentTypes.LORE); + if (data == null) + return null; + + List components = data.lines(); + if (components.size() <= index) + throw new IndexOutOfBoundsException("Lore index out of bounds: " + index + ", size: " + components.size()); + + return components.get(index); + } + + /** + * Add a lore line to the existing lore. + * + * @param line the lore line to add + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder addLoreLine(Component line) { + Preconditions.checkNotNull(line, "line cannot be null"); + + ItemLore data = itemStack.getData(DataComponentTypes.LORE); + + ItemLore.Builder builder = ItemLore.lore(); + if (data != null) + builder.lines(data.lines()); + + ItemLore itemLore = builder + .addLine(line.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)) + .build(); + + itemStack.setData(DataComponentTypes.LORE, itemLore); + return this; + } + + /** + * Replace a specific lore line at the given index. + * + * @param line the new line component + * @param index the index to set + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder setLoreLine(Component line, @NonNegative int index) { + Preconditions.checkNotNull(line, "line cannot be null"); + Preconditions.checkArgument(index >= 0, "index cannot be negative: %s", index); + + ItemLore data = itemStack.getData(DataComponentTypes.LORE); + if (data == null) + throw new IndexOutOfBoundsException("Cannot set lore line, item has no lore"); + + List lore = new ArrayList<>(data.lines()); + if (lore.size() <= index) + throw new IndexOutOfBoundsException("Lore index out of bounds: " + index + ", size: " + lore.size()); + + lore.set(index, line.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)); + + itemStack.setData(DataComponentTypes.LORE, ItemLore.lore(lore)); + return this; + } + + /** + * Remove the first matching lore line (by equality). + * + * @param line the component to remove + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeLoreLine(Component line) { + Preconditions.checkNotNull(line, "line cannot be null"); + + ItemLore data = itemStack.getData(DataComponentTypes.LORE); + if (data == null) + return this; + + List lore = new ArrayList<>(data.lines()); + lore.remove(line.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)); + + itemStack.setData(DataComponentTypes.LORE, ItemLore.lore(lore)); + return this; + } + + /** + * Remove a lore line by index. + * + * @param index the line index to remove + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeLoreLine(@NonNegative int index) { + Preconditions.checkArgument(index >= 0, "index cannot be negative: %s", index); + + ItemLore data = itemStack.getData(DataComponentTypes.LORE); + if (data == null) + throw new IndexOutOfBoundsException("Cannot set lore line: item has no lore"); + + List lore = new ArrayList<>(data.lines()); + lore.remove(index); + + itemStack.setData(DataComponentTypes.LORE, ItemLore.lore(lore)); + return this; + } + + /** + * Get the full lore as a list of components. + * + * @return list of lore components or null if not present + */ + @Nullable + public List lore() { + ItemLore lore = itemStack.getData(DataComponentTypes.LORE); + return lore == null ? null : lore.lines(); + } + + /** + * Set the tooltip style Key for this item ({@link DataComponentTypes#TOOLTIP_STYLE}). + * + * @param key the tooltip style key + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder tooltipStyle(Key key) { + Preconditions.checkNotNull(key, "key cannot be null"); + + itemStack.setData(DataComponentTypes.TOOLTIP_STYLE, key); + return this; + } + + /** + * Get the tooltip style key applied to this item ({@link DataComponentTypes#TOOLTIP_STYLE}). + * + * @return the Key or null if none set + */ + @Nullable + public Key tooltipStyle() { + return itemStack.getData(DataComponentTypes.TOOLTIP_STYLE); + } + + /** + * Add an attribute modifier for the specified attribute while preserving + * existing modifiers ({@link DataComponentTypes#ATTRIBUTE_MODIFIERS}). + * + * @param attribute the attribute to modify + * @param modifier the modifier to add + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder addAttributeModifier(Attribute attribute, AttributeModifier modifier) { + Preconditions.checkNotNull(attribute, "attribute cannot be null"); + Preconditions.checkNotNull(modifier, "modifier cannot be null"); + + ItemAttributeModifiers.Builder itemAttributeModifiers = ItemAttributeModifiers.itemAttributes() + .addModifier(attribute, modifier); + + ItemAttributeModifiers data = itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS); + if (data != null) + data.modifiers().forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); + return this; + } + + /** + * Add multiple attribute modifiers while preserving existing ones. + * + * @param attributeModifiers map of Attribute -> AttributeModifier + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder addAttributeModifiers(Map attributeModifiers) { + Preconditions.checkNotNull(attributeModifiers, "attributeModifiers cannot be null"); + + ItemAttributeModifiers.Builder itemAttributeModifiers = ItemAttributeModifiers.itemAttributes(); + if (itemStack.hasData(DataComponentTypes.ATTRIBUTE_MODIFIERS)) + itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); + + attributeModifiers.forEach(itemAttributeModifiers::addModifier); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); + return this; + } + + /** + * Overwrite attribute modifiers with a single attribute entry ({@link DataComponentTypes#ATTRIBUTE_MODIFIERS}). + * + * @param attribute the attribute + * @param modifier the modifier + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder attributeModifiers(Attribute attribute, AttributeModifier modifier) { + Preconditions.checkNotNull(attribute, "attribute cannot be null"); + Preconditions.checkNotNull(modifier, "modifier cannot be null"); + + ItemAttributeModifiers itemAttributeModifiers = ItemAttributeModifiers.itemAttributes() + .addModifier(attribute, modifier) + .build(); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers); + return this; + } + + /** + * Overwrite attribute modifiers with the provided map. + * + * @param attributeModifiers map of Attribute -> AttributeModifier + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder attributeModifiers(Map attributeModifiers) { + Preconditions.checkNotNull(attributeModifiers, "attributeModifiers cannot be null"); + + ItemAttributeModifiers.Builder itemAttributeModifiers = ItemAttributeModifiers.itemAttributes(); + attributeModifiers.forEach(itemAttributeModifiers::addModifier); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); + return this; + } + + /** + * Remove all modifiers for a single attribute. + * + * @param attribute the attribute to remove modifiers for + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeAttributeModifier(Attribute attribute) { + Preconditions.checkNotNull(attribute, "attribute cannot be null"); + + ItemAttributeModifiers.Builder itemAttributeModifiers = ItemAttributeModifiers.itemAttributes(); + if (!itemStack.hasData(DataComponentTypes.ATTRIBUTE_MODIFIERS)) + return this; + + itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().stream() + .filter(attModifier -> !attModifier.attribute().equals(attribute)) + .forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); + return this; + } + + /** + * Remove modifiers for the provided attributes. + * + * @param attributes the attributes to remove + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder removeAttributeModifiers(Attribute... attributes) { + Preconditions.checkNotNull(attributes, "attributes cannot be null"); + + List list = Arrays.asList(attributes); + ItemAttributeModifiers.Builder itemAttributeModifiers = ItemAttributeModifiers.itemAttributes(); + if (!itemStack.hasData(DataComponentTypes.ATTRIBUTE_MODIFIERS)) + return this; + + itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().stream() + .filter(attModifier -> !list.contains(attModifier.attribute())) + .forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); + + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); + return this; + } + + /** + * Remove all attribute modifiers (set empty). + * + * @return this builder + */ + public ItemBuilder removeAttributeModifiers() { + itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, ItemAttributeModifiers.itemAttributes().build()); + return this; + } + + /** + * Unset the attribute modifiers component entirely. + * + * @return this builder + */ + public ItemBuilder resetAttributesModifiers() { + itemStack.unsetData(DataComponentTypes.ATTRIBUTE_MODIFIERS); + return this; + } + + /** + * Get a map of attribute modifiers currently set on the item. + * + * @return a map of Attribute -> AttributeModifier (empty if none) + */ + public Map attributeModifiers() { + ItemAttributeModifiers data = itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS); + return data == null + ? Collections.emptyMap() + : data.modifiers().stream() + .collect(Collectors.toMap( + ItemAttributeModifiers.Entry::attribute, + ItemAttributeModifiers.Entry::modifier, + (existing, replacement) -> replacement + )); + } + + /** + * Set a custom model data component instance ({@link DataComponentTypes#CUSTOM_MODEL_DATA}). + * + * @param customModelData the CustomModelData object + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder customModelData(CustomModelData customModelData) { + Preconditions.checkNotNull(customModelData, "customModelData cannot be null"); + + itemStack.setData(DataComponentTypes.CUSTOM_MODEL_DATA, customModelData); + return this; + } + + /** + * Set a single float value for custom model data. + * + * @param customModelData float value to add + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder customModelData(float customModelData) { + itemStack.setData(DataComponentTypes.CUSTOM_MODEL_DATA, CustomModelData.customModelData().addFloat(customModelData).build()); + return this; + } + + /** + * Remove the {@link DataComponentTypes#CUSTOM_MODEL_DATA} component. + * + * @return this builder + */ + @Contract(value = "-> this", mutates = "this") + public ItemBuilder resetCustomModelData() { + itemStack.unsetData(DataComponentTypes.CUSTOM_MODEL_DATA); + return this; + } + + /** + * Get the CustomModelData component if present. + * + * @return the CustomModelData or null + */ + @Nullable + public CustomModelData customModelData() { + return itemStack.getData(DataComponentTypes.CUSTOM_MODEL_DATA); + } + + /** + * Set the maximum stack size for this item ({@link DataComponentTypes#MAX_STACK_SIZE}). + * + * @param maxStackSize value in range [1,99] + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder maxStackSize(@IntRange(from = 1, to = 99) int maxStackSize) { + Preconditions.checkArgument(maxStackSize >= 1 && maxStackSize <= 99, "maxStackSize must be between 1 and 99: %s", maxStackSize); + + itemStack.setData(DataComponentTypes.MAX_STACK_SIZE, maxStackSize); + return this; + } + + /** + * Get the {@link DataComponentTypes#MAX_STACK_SIZE} value if present. + * + * @return the integer max stack size or null + */ + public Integer maxStackSize() { + return itemStack.getData(DataComponentTypes.MAX_STACK_SIZE); + } + + /** + * Add a banner pattern to this item, preserving existing patterns + * ({@link DataComponentTypes#BANNER_PATTERNS}). + * + * @param pattern the pattern to add + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder addBannerPattern(Pattern pattern) { + Preconditions.checkNotNull(pattern, "pattern cannot be null"); + + BannerPatternLayers data = itemStack.getData(DataComponentTypes.BANNER_PATTERNS); + BannerPatternLayers.Builder builder = BannerPatternLayers.bannerPatternLayers(); + if (data != null) + builder.addAll(data.patterns()); + + builder.add(pattern); + itemStack.setData(DataComponentTypes.BANNER_PATTERNS, builder.build()); + return this; + } + + /** + * Add multiple banner patterns while preserving existing ones. + * + * @param patterns patterns to add + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder addBannerPatterns(Pattern... patterns) { + Preconditions.checkNotNull(patterns, "patterns cannot be null"); + + BannerPatternLayers data = itemStack.getData(DataComponentTypes.BANNER_PATTERNS); + BannerPatternLayers.Builder builder = BannerPatternLayers.bannerPatternLayers(); + if (data != null) + builder.addAll(data.patterns()); + + builder.addAll(Arrays.asList(patterns)); + itemStack.setData(DataComponentTypes.BANNER_PATTERNS, builder.build()); + return this; + } + + /** + * Overwrite banner patterns with the provided patterns. + * + * @param patterns banner patterns + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder bannerPatterns(Pattern... patterns) { + Preconditions.checkNotNull(patterns, "patterns cannot be null"); + + BannerPatternLayers bannerPatternLayers = BannerPatternLayers.bannerPatternLayers() + .addAll(Arrays.asList(patterns)) + .build(); + + itemStack.setData(DataComponentTypes.BANNER_PATTERNS, bannerPatternLayers); + return this; + } + + /** + * Overwrite banner patterns with the provided list. + * + * @param patterns list of patterns + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder bannerPatterns(List patterns) { + Preconditions.checkNotNull(patterns, "patterns cannot be null"); + + BannerPatternLayers bannerPatternLayers = BannerPatternLayers.bannerPatternLayers() + .addAll(patterns) + .build(); + + itemStack.setData(DataComponentTypes.BANNER_PATTERNS, bannerPatternLayers); + return this; + } + + /** + * Remove {@link DataComponentTypes#BANNER_PATTERNS} component. + * + * @return this builder + */ + @Contract(value = "-> this", mutates = "this") + public ItemBuilder resetBannerPatterns() { + itemStack.unsetData(DataComponentTypes.BANNER_PATTERNS); + return this; + } + + /** + * Get banner patterns applied to this item. + * + * @return list of patterns (empty if none) + */ + public List bannerPatterns() { + BannerPatternLayers data = itemStack.getData(DataComponentTypes.BANNER_PATTERNS); + return data == null ? Collections.emptyList() : data.patterns(); + } + + /** + * Set the dyed color for dyed items using {@link DataComponentTypes#DYED_COLOR}. + * + * @param color the java.awt-like Color (Bukkit Color) + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder dyeColor(Color color) { + Preconditions.checkNotNull(color, "color cannot be null"); + + itemStack.setData(DataComponentTypes.DYED_COLOR, DyedItemColor.dyedItemColor(color)); + return this; + } + + /** + * Get the dyed color applied to the item ({@link DataComponentTypes#DYED_COLOR}). + * + * @return the Color or null if not dyed + */ + @Nullable + public Color dyeColor() { + DyedItemColor data = itemStack.getData(DataComponentTypes.DYED_COLOR); + return data == null ? null : data.color(); + } + + /** + * Remove the {@link DataComponentTypes#DYED_COLOR} component from the item. + * + * @return this builder + */ + @Contract(value = "-> this", mutates = "this") + public ItemBuilder resetDyeColor() { + itemStack.unsetData(DataComponentTypes.DYED_COLOR); + return this; + } + + /** + * Get the material's corresponding DyeColor (for dyed variants). This + * resolves a large switch of materials to DyeColor and returns WHITE by + * default. + * + * @return the DyeColor for the current item type + */ + public DyeColor itemColor() { + return dyeColor(itemStack.getType()); + } + + /** + * Set an arbitrary valued data component on the item. + * + * @param type the valued DataComponentType + * @param value the value to set + * @param type parameter for the component value + * @return this builder + */ + @Contract(value = "_, _ -> this", mutates = "this") + public ItemBuilder component(DataComponentType.Valued type, T value) { + Preconditions.checkNotNull(type, "type cannot be null"); + Preconditions.checkNotNull(value, "value cannot be null"); + + itemStack.setData(type, value); + return this; + } + + /** + * Set an arbitrary non-valued data component on the item (presence flag). + * + * @param type the non-valued DataComponentType + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder component(DataComponentType.NonValued type) { + Preconditions.checkNotNull(type, "type cannot be null"); + itemStack.setData(type); + return this; + } + + /** + * Get a valued data component from the item. + * + * @param type the valued DataComponentType + * @param value type + * @return the component's value or null if not present + */ + @Nullable + public T component(DataComponentType.Valued type) { + Preconditions.checkNotNull(type, "type cannot be null"); + + return itemStack.getData(type); + } + + /** + * Remove/unset a data component from the item. + * + * @param type the component type to unset + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder resetComponent(DataComponentType type) { + Preconditions.checkNotNull(type, "type cannot be null"); + + itemStack.unsetData(type); + return this; + } + + /** + * Store a value in the item's PersistentDataContainer using an Adventure Key + * mapped to a Bukkit NamespacedKey. + * + * @param key the Adventure key used to create a NamespacedKey + * @param type the PersistentDataType for the value + * @param value the value to store + * @param

primitive type parameter + * @param complex type parameter + * @return this builder + */ + @Contract(value = "_, _, _ -> this", mutates = "this") + public ItemBuilder persistentData(Key key, PersistentDataType type, C value) { + Preconditions.checkNotNull(key, "key cannot be null"); + Preconditions.checkNotNull(type, "type cannot be null"); + Preconditions.checkNotNull(value, "value cannot be null"); + + itemStack.editPersistentDataContainer(pdc -> pdc.set(new NamespacedKey(key.namespace(), key.value()), type, value)); + return this; + } + + /** + * Hide specific data component types from the tooltip using {@link DataComponentTypes#TOOLTIP_DISPLAY}. + * Will not overwrite hideTooltip if it was already set to hide everything. + * + * @param typesToHide data component types to hide from tooltip + * @return this builder + */ + @SuppressWarnings({"deprecation", "java:S1874"}) + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder hide(DataComponentType... typesToHide) { + Preconditions.checkNotNull(typesToHide, "typesToHide cannot be null"); + + if (itemStack.hasData(DataComponentTypes.TOOLTIP_DISPLAY) && itemStack.getData(DataComponentTypes.TOOLTIP_DISPLAY).hideTooltip()) + return this; + + TooltipDisplay tooltipDisplay = TooltipDisplay.tooltipDisplay() + .addHiddenComponents(typesToHide) + .build(); + + itemStack.setData(DataComponentTypes.TOOLTIP_DISPLAY, tooltipDisplay); + + return this; + } + + /** + * Get the set of hidden data component types for the tooltip ({@link DataComponentTypes#TOOLTIP_DISPLAY}). + * + * @return set of hidden DataComponentType (empty if none) + */ + public Set hiddenComponents() { + if (!itemStack.hasData(DataComponentTypes.TOOLTIP_DISPLAY)) + return Collections.emptySet(); + + return itemStack.getData(DataComponentTypes.TOOLTIP_DISPLAY).hiddenComponents(); + } + + /** + * Set whether the tooltip should be hidden entirely (uses {@link DataComponentTypes#TOOLTIP_DISPLAY}). + * + * @param hideTooltip true to hide tooltip, false to show + * @return this builder + */ + @Contract(value = "_ -> this", mutates = "this") + public ItemBuilder hideTooltip(boolean hideTooltip) { + TooltipDisplay tooltipDisplay = TooltipDisplay.tooltipDisplay() + .hideTooltip(hideTooltip) + .build(); + + itemStack.setData(DataComponentTypes.TOOLTIP_DISPLAY, tooltipDisplay); + + return this; + } + + /** + * Check whether the tooltip is hidden entirely. + * + * @return true if tooltip is hidden + */ + public boolean hideTooltip() { + if (!itemStack.hasData(DataComponentTypes.TOOLTIP_DISPLAY)) + return false; + + return itemStack.getData(DataComponentTypes.TOOLTIP_DISPLAY).hideTooltip(); + } + + /* + * Internal helper: map a Material to its DyeColor. This covers many + * material constants and returns WHITE by default. + */ + private DyeColor dyeColor(Material material) { + return switch (material.name()) { + case "ORANGE_BANNER", "ORANGE_BED", "ORANGE_BUNDLE", "ORANGE_CANDLE", "ORANGE_CANDLE_CAKE", "ORANGE_CARPET", + "ORANGE_CONCRETE", "ORANGE_CONCRETE_POWDER", "ORANGE_DYE", "ORANGE_WOOL", "ORANGE_GLAZED_TERRACOTTA", + "ORANGE_TERRACOTTA", "ORANGE_SHULKER_BOX", "ORANGE_STAINED_GLASS", "ORANGE_STAINED_GLASS_PANE", + "ORANGE_WALL_BANNER", "ORANGE_HARNESS", "ORANGE_TULIP", "TORCHFLOWER", "OPEN_EYEBLOSSOM" -> + DyeColor.ORANGE; + + case "MAGENTA_BANNER", "MAGENTA_BED", "MAGENTA_BUNDLE", "MAGENTA_CANDLE", "MAGENTA_CANDLE_CAKE", + "MAGENTA_CARPET", + "MAGENTA_CONCRETE", "MAGENTA_CONCRETE_POWDER", "MAGENTA_DYE", "MAGENTA_WOOL", + "MAGENTA_GLAZED_TERRACOTTA", + "MAGENTA_TERRACOTTA", "MAGENTA_SHULKER_BOX", "MAGENTA_STAINED_GLASS", "MAGENTA_STAINED_GLASS_PANE", + "MAGENTA_WALL_BANNER", "MAGENTA_HARNESS", "ALLIUM", "LILAC" -> DyeColor.MAGENTA; + + case "LIGHT_BLUE_BANNER", "LIGHT_BLUE_BED", "LIGHT_BLUE_BUNDLE", "LIGHT_BLUE_CANDLE", + "LIGHT_BLUE_CANDLE_CAKE", + "LIGHT_BLUE_CARPET", "LIGHT_BLUE_CONCRETE", "LIGHT_BLUE_CONCRETE_POWDER", "LIGHT_BLUE_DYE", + "LIGHT_BLUE_WOOL", + "LIGHT_BLUE_GLAZED_TERRACOTTA", "LIGHT_BLUE_TERRACOTTA", "LIGHT_BLUE_SHULKER_BOX", + "LIGHT_BLUE_STAINED_GLASS", + "LIGHT_BLUE_STAINED_GLASS_PANE", "LIGHT_BLUE_WALL_BANNER", "LIGHT_BLUE_HARNESS" -> DyeColor.LIGHT_BLUE; + + case "YELLOW_BANNER", "YELLOW_BED", "YELLOW_BUNDLE", "YELLOW_CANDLE", "YELLOW_CANDLE_CAKE", "YELLOW_CARPET", + "YELLOW_CONCRETE", "YELLOW_CONCRETE_POWDER", "YELLOW_DYE", "YELLOW_WOOL", "YELLOW_GLAZED_TERRACOTTA", + "YELLOW_TERRACOTTA", "YELLOW_SHULKER_BOX", "YELLOW_STAINED_GLASS", "YELLOW_STAINED_GLASS_PANE", + "YELLOW_WALL_BANNER", "YELLOW_HARNESS", "DANDELION", "SUNFLOWER", "WILDFLOWERS" -> DyeColor.YELLOW; + + case "LIME_BANNER", "LIME_BED", "LIME_BUNDLE", "LIME_CANDLE", "LIME_CANDLE_CAKE", "LIME_CARPET", + "LIME_CONCRETE", + "LIME_CONCRETE_POWDER", "LIME_DYE", "LIME_WOOL", "LIME_GLAZED_TERRACOTTA", "LIME_TERRACOTTA", + "LIME_SHULKER_BOX", + "LIME_STAINED_GLASS", "LIME_STAINED_GLASS_PANE", "LIME_WALL_BANNER", "LIME_HARNESS", "SEA_PICKLE" -> + DyeColor.LIME; + + case "PINK_BANNER", "PINK_BED", "PINK_BUNDLE", "PINK_CANDLE", "PINK_CANDLE_CAKE", "PINK_CARPET", + "PINK_CONCRETE", + "PINK_CONCRETE_POWDER", "PINK_DYE", "PINK_WOOL", "PINK_GLAZED_TERRACOTTA", "PINK_TERRACOTTA", + "PINK_SHULKER_BOX", + "PINK_STAINED_GLASS", "PINK_STAINED_GLASS_PANE", "PINK_WALL_BANNER", "PINK_HARNESS", "PINK_TULIP", + "PEONY", + "PINK_PETALS" -> DyeColor.PINK; + + case "GRAY_BANNER", "GRAY_BED", "GRAY_BUNDLE", "GRAY_CANDLE", "GRAY_CANDLE_CAKE", "GRAY_CARPET", + "GRAY_CONCRETE", + "GRAY_CONCRETE_POWDER", "GRAY_DYE", "GRAY_WOOL", "GRAY_GLAZED_TERRACOTTA", "GRAY_TERRACOTTA", + "GRAY_SHULKER_BOX", + "GRAY_STAINED_GLASS", "GRAY_STAINED_GLASS_PANE", "GRAY_WALL_BANNER", "GRAY_HARNESS", + "CLOSED_EYEBLOSSOM" -> DyeColor.GRAY; + + case "LIGHT_GRAY_BANNER", "LIGHT_GRAY_BED", "LIGHT_GRAY_BUNDLE", "LIGHT_GRAY_CANDLE", + "LIGHT_GRAY_CANDLE_CAKE", + "LIGHT_GRAY_CARPET", "LIGHT_GRAY_CONCRETE", "LIGHT_GRAY_CONCRETE_POWDER", "LIGHT_GRAY_DYE", + "LIGHT_GRAY_WOOL", + "LIGHT_GRAY_GLAZED_TERRACOTTA", "LIGHT_GRAY_TERRACOTTA", "LIGHT_GRAY_SHULKER_BOX", + "LIGHT_GRAY_STAINED_GLASS", + "LIGHT_GRAY_STAINED_GLASS_PANE", "LIGHT_GRAY_WALL_BANNER", "LIGHT_GRAY_HARNESS", "AZURE_BLUET", + "OXEYE_DAISY", + "WHITE_TULIP" -> DyeColor.LIGHT_GRAY; + + case "CYAN_BANNER", "CYAN_BED", "CYAN_BUNDLE", "CYAN_CANDLE", "CYAN_CANDLE_CAKE", "CYAN_CARPET", + "CYAN_CONCRETE", + "CYAN_CONCRETE_POWDER", "CYAN_DYE", "CYAN_WOOL", "CYAN_GLAZED_TERRACOTTA", "CYAN_TERRACOTTA", + "CYAN_SHULKER_BOX", + "CYAN_STAINED_GLASS", "CYAN_STAINED_GLASS_PANE", "CYAN_WALL_BANNER", "CYAN_HARNESS", "PITCHER_PLANT" -> + DyeColor.CYAN; + + case "PURPLE_BANNER", "PURPLE_BED", "PURPLE_BUNDLE", "PURPLE_CANDLE", "PURPLE_CANDLE_CAKE", "PURPLE_CARPET", + "PURPLE_CONCRETE", "PURPLE_CONCRETE_POWDER", "PURPLE_DYE", "PURPLE_WOOL", "PURPLE_GLAZED_TERRACOTTA", + "PURPLE_TERRACOTTA", "PURPLE_SHULKER_BOX", "PURPLE_STAINED_GLASS", "PURPLE_STAINED_GLASS_PANE", + "PURPLE_WALL_BANNER", "PURPLE_HARNESS" -> DyeColor.PURPLE; + + case "BLUE_BANNER", "BLUE_BED", "BLUE_BUNDLE", "BLUE_CANDLE", "BLUE_CANDLE_CAKE", "BLUE_CARPET", + "BLUE_CONCRETE", + "BLUE_CONCRETE_POWDER", "BLUE_DYE", "BLUE_WOOL", "BLUE_GLAZED_TERRACOTTA", "BLUE_TERRACOTTA", + "BLUE_SHULKER_BOX", + "BLUE_STAINED_GLASS", "BLUE_STAINED_GLASS_PANE", "BLUE_WALL_BANNER", "BLUE_HARNESS", "CORNFLOWER", + "BLUE_ORCHID" -> DyeColor.BLUE; + + case "BROWN_BANNER", "BROWN_BED", "BROWN_BUNDLE", "BROWN_CANDLE", "BROWN_CANDLE_CAKE", "BROWN_CARPET", + "BROWN_CONCRETE", + "BROWN_CONCRETE_POWDER", "BROWN_DYE", "BROWN_WOOL", "BROWN_GLAZED_TERRACOTTA", "BROWN_TERRACOTTA", + "BROWN_SHULKER_BOX", "BROWN_STAINED_GLASS", "BROWN_STAINED_GLASS_PANE", "BROWN_WALL_BANNER", + "BROWN_HARNESS", + "COCOA_BEANS" -> DyeColor.BROWN; + + case "GREEN_BANNER", "GREEN_BED", "GREEN_BUNDLE", "GREEN_CANDLE", "GREEN_CANDLE_CAKE", "GREEN_CARPET", + "GREEN_CONCRETE", + "GREEN_CONCRETE_POWDER", "GREEN_DYE", "GREEN_WOOL", "GREEN_GLAZED_TERRACOTTA", "GREEN_TERRACOTTA", + "GREEN_SHULKER_BOX", "GREEN_STAINED_GLASS", "GREEN_STAINED_GLASS_PANE", "GREEN_WALL_BANNER", + "GREEN_HARNESS", + "CACTUS" -> DyeColor.GREEN; + + case "RED_BANNER", "RED_BED", "RED_BUNDLE", "RED_CANDLE", "RED_CANDLE_CAKE", "RED_CARPET", "RED_CONCRETE", + "RED_CONCRETE_POWDER", "RED_DYE", "RED_WOOL", "RED_GLAZED_TERRACOTTA", "RED_TERRACOTTA", + "RED_SHULKER_BOX", + "RED_STAINED_GLASS", "RED_STAINED_GLASS_PANE", "RED_WALL_BANNER", "RED_HARNESS", "POPPY", "RED_TULIP", + "ROSE_BUSH", + "BEETROOT" -> DyeColor.RED; + + case "BLACK_BANNER", "BLACK_BED", "BLACK_BUNDLE", "BLACK_CANDLE", "BLACK_CANDLE_CAKE", "BLACK_CARPET", + "BLACK_CONCRETE", + "BLACK_CONCRETE_POWDER", "BLACK_DYE", "BLACK_WOOL", "BLACK_GLAZED_TERRACOTTA", "BLACK_TERRACOTTA", + "BLACK_SHULKER_BOX", "BLACK_STAINED_GLASS", "BLACK_STAINED_GLASS_PANE", "BLACK_WALL_BANNER", + "BLACK_HARNESS", + "INK_SAC", "WITHER_ROSE" -> DyeColor.BLACK; + + default -> DyeColor.WHITE; + }; + } +} \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/VersionUtils.java b/src/main/java/fr/kikoplugins/kikoapi/utils/VersionUtils.java index b52cabb..8499b40 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/utils/VersionUtils.java +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/VersionUtils.java @@ -18,7 +18,7 @@ public enum VersionUtils { this.value = value; } - public static synchronized VersionUtils version() { + public static synchronized VersionUtils getVersion() { if (serverVersion != null) return serverVersion; @@ -52,24 +52,24 @@ private static VersionUtils protocolVersionMethod() { } public static boolean isHigherThan(VersionUtils target) { - return version().value > target.value; + return getVersion().value > target.value; } public static boolean isHigherThanOrEquals(VersionUtils target) { - return version().value >= target.value; + return getVersion().value >= target.value; } public static boolean isLowerThan(VersionUtils target) { - return version().value < target.value; + return getVersion().value < target.value; } public static boolean isLowerThanOrEquals(VersionUtils target) { - return version().value <= target.value; + return getVersion().value <= target.value; } @SuppressWarnings("java:S1201") public static boolean equals(VersionUtils target) { - return version().value == target.value; + return getVersion().value == target.value; } @Override diff --git a/src/test/java/fr/kikoplugins/kikoapi/utils/EnumUtilsTest.java b/src/test/java/fr/kikoplugins/kikoapi/utils/EnumUtilsTest.java new file mode 100644 index 0000000..10f0bac --- /dev/null +++ b/src/test/java/fr/kikoplugins/kikoapi/utils/EnumUtilsTest.java @@ -0,0 +1,23 @@ +package fr.kikoplugins.kikoapi.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class EnumUtilsTest { + @Test + @DisplayName("Test match methods") + @SuppressWarnings("java:S115") + void testMatchMethods() { + enum TestEnum {VALUE_ONE, VALUE_TWO, VALUE_THREE, value_four} + + assertEquals(TestEnum.VALUE_ONE, EnumUtils.match("value_one", TestEnum.class).orElse(null)); + assertEquals(TestEnum.VALUE_TWO, EnumUtils.match("VALUE_TWO", TestEnum.class).orElse(null)); + assertNull(EnumUtils.match("invalid_value", TestEnum.class).orElse(null)); + assertEquals(TestEnum.VALUE_THREE, EnumUtils.match("value_three", TestEnum.class, TestEnum.VALUE_ONE)); + assertEquals(TestEnum.VALUE_ONE, EnumUtils.match("invalid_value", TestEnum.class, TestEnum.VALUE_ONE)); + assertEquals(TestEnum.value_four, EnumUtils.match("VALUE_FOUR", TestEnum.class).orElse(null)); + } +} diff --git a/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java b/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java new file mode 100644 index 0000000..e39ee28 --- /dev/null +++ b/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java @@ -0,0 +1,1113 @@ +package fr.kikoplugins.kikoapi.utils; + +import fr.kikoplugins.kikoapi.KikoAPI; +import fr.kikoplugins.kikoapi.mock.KikoServerMock; +import fr.kikoplugins.kikoapi.mock.MockBukkitHelper; +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Color; +import org.bukkit.DyeColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.block.banner.Pattern; +import org.bukkit.block.banner.PatternType; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class ItemBuilderTest { + private KikoServerMock server; + + @BeforeEach + void setUp() { + this.server = MockBukkitHelper.safeMock(); + MockBukkit.load(KikoAPI.class); + } + + @AfterEach + void tearDown() { + MockBukkitHelper.safeUnmock(); + } + + @Nested + class Constructor { + @Test + @DisplayName("of(Material) should create builder with non-air material and default amount 1") + void ofMaterial_shouldReturnBuilderWhenMaterialIsNotAir() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + ItemStack built = builder.build(); + + assertEquals(Material.DIAMOND_SWORD, built.getType()); + assertEquals(1, built.getAmount()); + } + + @Test + @DisplayName("of(Material) should throw IllegalArgumentException when material is air") + void ofMaterial_shouldThrowWhenMaterialIsAir() { + assertThrows(IllegalArgumentException.class, () -> ItemBuilder.of(Material.AIR)); + } + + @Test + @DisplayName("of(ItemStack) should wrap existing non-air ItemStack without copying") + void ofItemStack_shouldWrapExistingItemStackWhenMaterialNotAir() { + ItemStack base = ItemStack.of(Material.EMERALD, 3); + ItemBuilder builder = ItemBuilder.of(base); + + assertSame(base, builder.build()); + } + + @Test + @DisplayName("of(ItemStack) should throw IllegalArgumentException when item type is air") + void ofItemStack_shouldThrowWhenItemTypeIsAir() { + ItemStack air = ItemStack.of(Material.AIR); + assertThrows(IllegalArgumentException.class, () -> ItemBuilder.of(air)); + } + + @Test + @DisplayName("of(Material,int) should create builder with given amount when material not air") + void ofMaterialAndAmount_shouldReturnBuilderWhenMaterialNotAirAndAmountValid() { + ItemBuilder builder = ItemBuilder.of(Material.APPLE, 5); + ItemStack built = builder.build(); + + assertEquals(Material.APPLE, built.getType()); + assertEquals(5, built.getAmount()); + } + + @Test + @DisplayName("of(Material,int) should throw IllegalArgumentException when material is air") + void ofMaterialAndAmount_shouldThrowWhenMaterialIsAir() { + assertThrows(IllegalArgumentException.class, () -> ItemBuilder.of(Material.AIR, 5)); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, -10}) + @DisplayName("of(Material,int) should throw IllegalArgumentException when amount less than one") + void ofMaterialAndAmount_shouldThrowWhenAmountLessThanOne(int amount) { + assertThrows(IllegalArgumentException.class, () -> ItemBuilder.of(Material.STONE, amount)); + } + + @Test + @DisplayName("build should return underlying ItemStack instance") + void build_shouldReturnUnderlyingItemStackWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.GOLD_INGOT); + ItemStack built = builder.build(); + + assertSame(built, builder.build()); + } + + @Test + @DisplayName("buildCopy should return a cloned ItemStack independent of original") + void buildCopy_shouldReturnCloneOfWrappedItemStack() { + ItemBuilder builder = ItemBuilder.of(Material.IRON_INGOT, 4); + ItemStack original = builder.build(); + ItemStack copy = builder.buildCopy(); + + assertEquals(original, copy); + assertNotSame(original, copy); + } + + @Test + @DisplayName("copy should return new ItemBuilder wrapping a clone of the ItemStack") + void copy_shouldReturnNewBuilderWrappingCloneOfItemStack() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND, 2); + ItemBuilder copied = builder.copy(); + + assertNotSame(builder, copied); + assertEquals(builder.build(), copied.build()); + assertNotSame(builder.build(), copied.build()); + } + } + + @SuppressWarnings("UnstableApiUsage") + @Nested + class GetterSetter { + @ParameterizedTest + @ValueSource(ints = {1, 5, 64}) + @DisplayName("amount(int) should set stack size for valid amounts greater than zero") + void amountSetter_shouldSetAmountWhenGreaterThanZero(int amount) { + ItemBuilder builder = ItemBuilder.of(Material.APPLE); + builder.amount(amount); + + assertEquals(amount, builder.build().getAmount()); + assertEquals(amount, builder.amount()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, -5}) + @DisplayName("amount(int) should throw IllegalArgumentException for invalid amounts less than one") + void amountSetter_shouldThrowWhenAmountLessThanOne(int amount) { + ItemBuilder builder = ItemBuilder.of(Material.APPLE); + assertThrows(IllegalArgumentException.class, () -> builder.amount(amount)); + } + + @Test + @DisplayName("amount() should return current stack size of the item") + void amountGetter_shouldReturnCurrentStackSizeWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.APPLE, 7); + assertEquals(7, builder.amount()); + } + + @Test + @DisplayName("name(Component) should set and return custom item name through ITEM_NAME component") + void name_shouldSetAndReturnCustomItemNameWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + + assertNull(builder.name()); + + Component name = Component.text("Epic sword"); + builder.name(name); + + assertEquals(name, builder.name()); + assertEquals(name, builder.build().getData(DataComponentTypes.ITEM_NAME)); + } + + @Test + @DisplayName("renamableName(Component) should set and return custom name through CUSTOM_NAME component") + void renamableName_shouldSetAndReturnCustomNameWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + + assertNull(builder.renamableName()); + + Component name = Component.text("Rename Me"); + builder.renamableName(name); + + Component stored = builder.renamableName(); + assertEquals(name.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE), stored); + } + + @Test + @DisplayName("renamableName(Component) should set italic decoration to false when not specified") + void renamableName_shouldSetItalicFalseWhenExplicitlySpecified() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Component original = Component.text("No italics specified"); + builder.renamableName(original); + + Component stored = builder.renamableName(); + assertEquals(TextDecoration.State.FALSE, stored.decoration(TextDecoration.ITALIC)); + } + + @Test + @DisplayName("renamable(Component) should preserve existing italic decoration when already specified") + void renamableName_shouldPreserveItalicDecorationWhenAlreadySpecified() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Component original = Component.text("Already italic").decorate(TextDecoration.ITALIC); + builder.renamableName(original); + + Component stored = builder.renamableName(); + assertEquals(TextDecoration.State.TRUE, stored.decoration(TextDecoration.ITALIC)); + } + + @Test + @DisplayName("itemModel(Key) should set and get ITEM_MODEL data component") + void itemModel_shouldSetAndReturnModelKeyWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Key modelKey = Key.key("minecraft", "netherite_ingot"); + builder.itemModel(modelKey); + + assertEquals(modelKey, builder.itemModel()); + assertEquals(modelKey, builder.build().getData(DataComponentTypes.ITEM_MODEL)); + } + + @Test + @DisplayName("damage(int) should set and return DAMAGE component value") + void damage_shouldSetAndReturnDamageValueWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.IRON_SWORD); + assertNull(builder.damage()); + + builder.damage(10); + + assertEquals(10, builder.damage()); + assertEquals(10, builder.build().getData(DataComponentTypes.DAMAGE)); + } + + @Test + @DisplayName("durability(int) should set remaining durability based on MAX_DAMAGE and DAMAGE") + void durability_shouldSetRemainingDurabilityWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.IRON_SWORD); + builder.maxDamage(Material.IRON_SWORD.asItemType().getMaxDurability()); + + int maxDamage = builder.maxDamage(); + int remaining = maxDamage - 5; + builder.durability(remaining); + + assertEquals(remaining, builder.durability()); + assertEquals(maxDamage - remaining, builder.build().getData(DataComponentTypes.DAMAGE)); + } + + @Test + @DisplayName("addEnchantment should add single enchantment and preserve existing ones") + void addEnchantment_shouldAddSingleEnchantmentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + builder.addEnchantment(Enchantment.SWEEPING_EDGE, 3); + + ItemEnchantments enchantments = builder.build().getData(DataComponentTypes.ENCHANTMENTS); + assertNotNull(enchantments); + + Map map = enchantments.enchantments(); + assertEquals(2, map.size()); + assertEquals(5, map.get(Enchantment.SHARPNESS)); + assertEquals(3, map.get(Enchantment.SWEEPING_EDGE)); + } + + @Test + @DisplayName("enchantment should overwrite all enchantments with single entry") + void enchantment_shouldOverwriteEnchantmentsWithSingleEntryWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + + builder.enchantment(Enchantment.SWEEPING_EDGE, 2); + + Map map = builder.enchantmentsMap(); + assertEquals(1, map.size()); + assertEquals(2, map.get(Enchantment.SWEEPING_EDGE)); + } + + @Test + @DisplayName("addEnchantments should add multiple enchantments and preserve existing ones") + void addEnchantments_shouldAddMultipleEnchantmentsAndPreserveExistingWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + + Map more = new HashMap<>(); + more.put(Enchantment.SWEEPING_EDGE, 3); + more.put(Enchantment.UNBREAKING, 4); + + builder.addEnchantments(more); + + Map map = builder.enchantmentsMap(); + assertEquals(3, map.size()); + assertEquals(5, map.get(Enchantment.SHARPNESS)); + assertEquals(3, map.get(Enchantment.SWEEPING_EDGE)); + assertEquals(4, map.get(Enchantment.UNBREAKING)); + } + + @Test + @DisplayName("enchantments(Map) should overwrite all enchantments with provided map") + void enchantmentsMapSetter_shouldOverwriteAllEnchantmentsWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + + Map map = new HashMap<>(); + map.put(Enchantment.EFFICIENCY, 3); + builder.enchantments(map); + + Map result = builder.enchantmentsMap(); + assertEquals(1, result.size()); + assertEquals(3, result.get(Enchantment.EFFICIENCY)); + } + + @Test + @DisplayName("removeEnchantment should remove single enchantment when present") + void removeEnchantment_shouldRemoveSingleEnchantmentWhenPresent() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + builder.addEnchantment(Enchantment.SWEEPING_EDGE, 2); + + builder.removeEnchantment(Enchantment.SHARPNESS); + + Map map = builder.enchantmentsMap(); + assertEquals(1, map.size()); + assertFalse(map.containsKey(Enchantment.SHARPNESS)); + assertTrue(map.containsKey(Enchantment.SWEEPING_EDGE)); + } + + @Test + @DisplayName("removeEnchantments(Enchantment...) should remove all specified enchantments when present") + void removeEnchantmentsVarargs_shouldRemoveMultipleEnchantmentsWhenPresent() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + builder.addEnchantment(Enchantment.SWEEPING_EDGE, 2); + builder.addEnchantment(Enchantment.UNBREAKING, 3); + + builder.removeEnchantments(Enchantment.SHARPNESS, Enchantment.SWEEPING_EDGE); + + Map map = builder.enchantmentsMap(); + assertEquals(1, map.size()); + assertTrue(map.containsKey(Enchantment.UNBREAKING)); + } + + @Test + @DisplayName("removeEnchantments() should remove all enchantments leaving empty ItemEnchantments component") + void removeEnchantmentsNoArgs_shouldRemoveAllEnchantmentsWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addEnchantment(Enchantment.SHARPNESS, 5); + + builder.removeEnchantments(); + + ItemEnchantments enchantments = builder.build().getData(DataComponentTypes.ENCHANTMENTS); + assertNotNull(enchantments); + assertTrue(enchantments.enchantments().isEmpty()); + } + + @Test + @DisplayName("enchantments() should return ItemEnchantments or null when none set") + void enchantmentsGetter_shouldReturnItemEnchantmentsOrNullWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + assertNull(builder.enchantments()); + + builder.addEnchantment(Enchantment.SHARPNESS, 5); + assertNotNull(builder.enchantments()); + } + + @Test + @DisplayName("enchantmentsMap() should return empty map when no enchantments set") + void enchantmentsMap_shouldReturnEmptyMapWhenNoEnchantmentsSet() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + assertTrue(builder.enchantmentsMap().isEmpty()); + } + + @Test + @DisplayName("forceGlowing(true) should set ENCHANTMENT_GLINT_OVERRIDE and forcedGlowing should return true") + void forceGlowing_shouldSetGlintOverrideWhenTrue() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.forceGlowing(true); + + assertTrue(builder.forcedGlowing()); + assertTrue(builder.build().hasData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE)); + } + + @Test + @DisplayName("forceGlowing(false) should set ENCHANTMENT_GLINT_OVERRIDE and forcedGlowing() should return false") + void forceGlowing_shouldSetGlintOverrideWhenFalse() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.forceGlowing(true); + + builder.forceGlowing(false); + + assertFalse(builder.forcedGlowing()); + assertFalse(builder.build().getData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE)); + } + + @Test + @DisplayName("resetGlowing should unset ENCHANTMENT_GLINT_OVERRIDE and forcedGlowing() should return false") + void resetGlowing_shouldRemoveGlintOverrideWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.forceGlowing(true); + + builder.resetGlowing(); + + assertFalse(builder.forcedGlowing()); + assertFalse(builder.build().hasData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE)); + } + + @Disabled("Freeze the tests") + @Test + @DisplayName("headTexture(OfflinePlayer) should store profile with player's UUID") + void headTextureOfflinePlayer_shouldStoreProfileWithPlayerUUIDWhenCalled() { + PlayerMock player = server.addPlayer("HeadOwner"); + ItemBuilder builder = ItemBuilder.of(Material.PLAYER_HEAD); + builder.headTexture(player); + + ResolvableProfile profile = builder.headTextureProfile(); + assertNotNull(profile); + assertEquals(player.getUniqueId(), profile.uuid()); + } + + @Test + @DisplayName("headTexture(String) should store base64 encoded textures property for given URL") + void headTextureString_shouldStoreBase64TexturesPropertyWhenCalled() { + String url = "https//textures.minecraft.net/texture/test_texture"; + ItemBuilder builder = ItemBuilder.of(Material.PLAYER_HEAD); + builder.headTexture(url); + + String base64 = builder.headTextureBase64(); + assertNotNull(base64); + + String expectedJson = "{\"textures\":{\"SKIN\":{\"url\":\"%s\"}}}".formatted(url); + String expectedBase64 = Base64.getEncoder().encodeToString(expectedJson.getBytes(StandardCharsets.UTF_8)); + + assertEquals(expectedBase64, base64); + } + + @Test + @DisplayName("headTextureBase64 should return null when no profile or no textures property configured") + void headTextureBase64_shouldReturnNullWhenNoProfileOrTexturesProperty() { + ItemBuilder builder = ItemBuilder.of(Material.PLAYER_HEAD); + assertNull(builder.headTextureBase64()); + } + + + @Disabled("Freeze the tests") + @Test + @DisplayName("headTextureProfile should return null when profile not set and non-null when set") + void headTextureProfile_shouldReturnNullWhenNotSetAndNonNullWhenSet() { + ItemBuilder builder = ItemBuilder.of(Material.PLAYER_HEAD); + assertNull(builder.headTextureProfile()); + + PlayerMock player = server.addPlayer("HeadOwnerB"); + builder.headTexture(player); + + assertNotNull(builder.headTextureProfile()); + } + + @Test + @DisplayName("unbreakable(true) should set UNBREAKABLE component and unbreakable() should return true") + void unbreakableSetter_shouldSetUnbreakableWhenTrue() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.unbreakable(true); + + assertTrue(builder.unbreakable()); + assertTrue(builder.build().hasData(DataComponentTypes.UNBREAKABLE)); + } + + @Test + @DisplayName("unbreakable(false) should unset UNBREAKABLE component and unbreakable() should return false") + void unbreakableSetter_shouldUnsetUnbreakableWhenFalse() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.unbreakable(true); + + builder.unbreakable(false); + + assertFalse(builder.unbreakable()); + assertFalse(builder.build().hasData(DataComponentTypes.UNBREAKABLE)); + } + + @Test + @DisplayName("lore(Component...) should set lore lines with italic decoration set to false by default") + void loreVarargs_shouldSetLoreLinesWithItalicFalseByDefault() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Component line1 = Component.text("First line"); + Component line2 = Component.text("Second line"); + + builder.lore(line1, line2); + + ItemLore lore = builder.build().getData(DataComponentTypes.LORE); + assertNotNull(lore); + + List lines = lore.lines(); + assertEquals(2, lines.size()); + assertEquals(TextDecoration.State.FALSE, lines.get(0).decoration(TextDecoration.ITALIC)); + assertEquals(TextDecoration.State.FALSE, lines.get(1).decoration(TextDecoration.ITALIC)); + } + + @Test + @DisplayName("lore(List) should set lore from list and apply default italic decoration behavior") + void loreList_shouldSetLoreLinesFromListWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + List lines = List.of( + Component.text("Line A"), + Component.text("Line B") + ); + + builder.lore(lines); + + ItemLore lore = builder.build().getData(DataComponentTypes.LORE); + assertNotNull(lore); + assertEquals(2, lore.lines().size()); + } + + @Test + @DisplayName("removeLore should unset LORE component and lore() should return null") + void removeLore_shouldUnsetLoreComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.lore(Component.text("To be removed")); + + builder.removeLore(); + + assertFalse(builder.build().hasData(DataComponentTypes.LORE)); + assertNull(builder.lore()); + } + + @Test + @DisplayName("loreLine should return specific lore line by index when present") + void loreLine_shouldReturnLoreComponentWhenIndexWithinBounds() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.lore(Component.text("Line 0"), Component.text("Line 1")); + + Component line = builder.loreLine(1); + assertEquals( + Component.text("Line 1").decorationIfAbsent( + TextDecoration.ITALIC, + TextDecoration.State.FALSE + ), + line + ); + } + + @Test + @DisplayName("loreLine should throw IndexOutOfBoundsException when index is out of bounds or lore not present") + void loreLine_shouldReturnThrowWhenIndexOutOfBoundsOrLoreMissing() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + assertNull(builder.loreLine(0)); + + builder.lore(Component.text("Only line")); + assertThrows(IndexOutOfBoundsException.class, () -> builder.loreLine(5)); + } + + @Test + @DisplayName("addLoreLine should append new lore line when lore already exists") + void addLoreLine_shouldAppendNewLoreLineWhenLoreExists() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.lore(Component.text("Line 0")); + + builder.addLoreLine(Component.text("Line 1")); + + List lore = builder.lore(); + assertNotNull(lore); + assertEquals(2, lore.size()); + assertEquals( + Component.text("Line 1").decorationIfAbsent( + TextDecoration.ITALIC, + TextDecoration.State.FALSE + ), + lore.get(1) + ); + } + + @Test + @DisplayName("setLoreLine should replace lore line at given index when present") + void setLoreLine_shouldReplaceLoreLineWhenIndexValid() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.lore(Component.text("Old line")); + + builder.setLoreLine(Component.text("New line"), 0); + + assertEquals( + Component.text("New line").decorationIfAbsent( + TextDecoration.ITALIC, + TextDecoration.State.FALSE + ), + builder.lore().getFirst() + ); + } + + @Test + @DisplayName("removeLoreLine(Component) should remove first matching lore line when present") + void removeLoreLineByComponent_shouldRemoveFirstMatchingLineWhenPresent() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Component toRemove = Component.text("Remove me"); + builder.lore(toRemove, Component.text("Other line")); + + builder.removeLoreLine(toRemove); + + List lore = builder.lore(); + assertNotNull(lore); + assertEquals(1, lore.size()); + assertEquals( + Component.text("Other line").decorationIfAbsent( + TextDecoration.ITALIC, + TextDecoration.State.FALSE + ), + lore.getFirst() + ); + } + + @Test + @DisplayName("removeLoreLine(int) should remove lore line at given index when present") + void removeLoreLineByIndex_shouldRemoveLoreLineWhenIndexValid() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.lore(Component.text("Line 0"), Component.text("Line 1")); + + builder.removeLoreLine(0); + + List lore = builder.lore(); + assertNotNull(lore); + assertEquals(1, lore.size()); + assertEquals( + Component.text("Line 1").decorationIfAbsent( + TextDecoration.ITALIC, + TextDecoration.State.FALSE + ), + lore.getFirst() + ); + } + + @Test + @DisplayName("lore() should return full lore list or null when none set") + void loreGetter_shouldReturnLoreListOrNullWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + assertNull(builder.lore()); + + builder.lore(Component.text("Present")); + assertNotNull(builder.lore()); + assertEquals(1, builder.lore().size()); + } + + @Test + @DisplayName("tooltipStyle(Key) should set and get TOOLTIP_STYLE component value") + void tooltipStyle_shouldSetAndReturnKeyWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Key key = Key.key("niveria", "test_style"); + + builder.tooltipStyle(key); + + assertEquals(key, builder.tooltipStyle()); + assertEquals(key, builder.build().getData(DataComponentTypes.TOOLTIP_STYLE)); + } + + @Test + @DisplayName("tooltipStyle() should return null when no tooltip style set") + void tooltipStyleGetter_shouldReturnNullWhenNotSet() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + assertNull(builder.tooltipStyle()); + } + + private AttributeModifier newTestModifier(double amount) { + return new AttributeModifier( + new NamespacedKey("niveria", UUID.randomUUID().toString()), + amount, + AttributeModifier.Operation.ADD_NUMBER + ); + } + + @Test + @DisplayName("addAttributeModifier should add modifier and preserve existing modifiers") + void addAttributeModifier_shouldAddModifierAndPreserveExistingWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + AttributeModifier mod1 = newTestModifier(5.0); + AttributeModifier mod2 = newTestModifier(2.0); + + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, mod1); + builder.addAttributeModifier(Attribute.ATTACK_SPEED, mod2); + + Map map = builder.attributeModifiers(); + assertEquals(2, map.size()); + assertEquals(mod1, map.get(Attribute.ATTACK_DAMAGE)); + assertEquals(mod2, map.get(Attribute.ATTACK_SPEED)); + } + + @Test + @DisplayName("addAttributeModifiers should add multiple modifiers and preserve existing ones") + void addAttributeModifiers_shouldAddMultipleModifiersAndPreserveExistingWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + AttributeModifier existing = newTestModifier(5.0); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, existing); + + Map more = new HashMap<>(); + more.put(Attribute.ATTACK_SPEED, newTestModifier(1.0)); + more.put(Attribute.MAX_HEALTH, newTestModifier(2.0)); + + builder.addAttributeModifiers(more); + + Map result = builder.attributeModifiers(); + assertEquals(3, result.size()); + } + + @Test + @DisplayName("attributeModifiers(Attribute,Modifier) should overwrite modifiers with single attribute entry") + void attributeModifiersSingle_shouldOverwriteWithSingleEntryWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + + AttributeModifier replacement = newTestModifier(10.0); + builder.attributeModifiers(Attribute.MAX_HEALTH, replacement); + + Map map = builder.attributeModifiers(); + assertEquals(1, map.size()); + assertEquals(replacement, map.get(Attribute.MAX_HEALTH)); + } + + @Test + @DisplayName("attributeModifiers(Map) should overwrite all modifiers with provided map") + void attributeModifiersMapSetter_shouldOverwriteAllModifiersWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + + Map map = new HashMap<>(); + AttributeModifier mod = newTestModifier(3.0); + map.put(Attribute.ARMOR, mod); + + builder.attributeModifiers(map); + + Map result = builder.attributeModifiers(); + assertEquals(1, result.size()); + assertEquals(mod, result.get(Attribute.ARMOR)); + } + + @Test + @DisplayName("removeAttributeModifier should remove all modifiers for specified attribute") + void removeAttributeModifier_shouldRemoveAllModifiersForAttributeWhenPresent() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + builder.addAttributeModifier(Attribute.ATTACK_SPEED, newTestModifier(1.0)); + + builder.removeAttributeModifier(Attribute.ATTACK_DAMAGE); + + Map result = builder.attributeModifiers(); + assertFalse(result.containsKey(Attribute.ATTACK_DAMAGE)); + assertTrue(result.containsKey(Attribute.ATTACK_SPEED)); + } + + @Test + @DisplayName("removeAttributeModifiers(Attribute...) should remove modifiers for all specified attributes") + void removeAttributeModifiersVarargs_shouldRemoveSpecifiedAttributesWhenPresent() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + builder.addAttributeModifier(Attribute.ATTACK_SPEED, newTestModifier(1.0)); + builder.addAttributeModifier(Attribute.ARMOR, newTestModifier(2.0)); + + builder.removeAttributeModifiers( + Attribute.ATTACK_DAMAGE, + Attribute.ARMOR + ); + + Map result = builder.attributeModifiers(); + assertEquals(1, result.size()); + assertTrue(result.containsKey(Attribute.ATTACK_SPEED)); + } + + @Test + @DisplayName("removeAttributeModifiers() should clear modifiers leaving empty ATTRIBUTE_MODIFIERS component") + void removeAttributeModifiersNoArgs_shouldClearModifiersWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + + builder.removeAttributeModifiers(); + + ItemAttributeModifiers data = builder.build().getData(DataComponentTypes.ATTRIBUTE_MODIFIERS); + assertNotNull(data); + assertTrue(data.modifiers().isEmpty()); + } + + @Test + @DisplayName("resetAttributesModifiers should unset ATTRIBUTE_MODIFIERS component and clear map") + void resetAttributesModifiers_shouldUnsetComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, newTestModifier(5.0)); + + builder.resetAttributesModifiers(); + + assertFalse(builder.build().hasData(DataComponentTypes.ATTRIBUTE_MODIFIERS)); + assertTrue(builder.attributeModifiers().isEmpty()); + } + + @Test + @DisplayName("attributeModifiers() should return map of attribute modifiers or empty when none set") + void attributeModifiersGetter_shouldReturnMapOfModifiersOrEmptyWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + assertTrue(builder.attributeModifiers().isEmpty()); + + AttributeModifier mod = newTestModifier(5.0); + builder.addAttributeModifier(Attribute.ATTACK_DAMAGE, mod); + + Map map = builder.attributeModifiers(); + assertEquals(1, map.size()); + assertEquals(mod, map.get(Attribute.ATTACK_DAMAGE)); + } + + @Test + @DisplayName("customModelData(CustomModelData) should set and return custom model data object") + void customModelDataObjectSetter_shouldSetAndReturnWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + CustomModelData data = CustomModelData.customModelData() + .addFloat(1.5F) + .build(); + + builder.customModelData(data); + + assertEquals(data, builder.customModelData()); + assertEquals(data, builder.build().getData(DataComponentTypes.CUSTOM_MODEL_DATA)); + } + + @Test + @DisplayName("customModelData(float) should create and set CUSTOM_MODEL_DATA with single float value") + void customModelDataFloatSetter_shouldCreateAndSetWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.customModelData(2.5F); + + assertNotNull(builder.customModelData()); + assertTrue(builder.build().hasData(DataComponentTypes.CUSTOM_MODEL_DATA)); + } + + @Test + @DisplayName("resetCustomModelData should unset CUSTOM_MODEL_DATA component and customModelData() should return null") + void resetCustomModelData_shouldUnsetComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.customModelData(1.0F); + + builder.resetCustomModelData(); + + assertNull(builder.customModelData()); + assertFalse(builder.build().hasData(DataComponentTypes.CUSTOM_MODEL_DATA)); + } + + @Test + @DisplayName("customModelData() should return null when no custom model data set") + void customModelDataGetter_shouldReturnNullWhenNotSet() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + assertNull(builder.customModelData()); + } + + @ParameterizedTest + @ValueSource(ints = {1, 16, 99}) + @DisplayName("maxStackSize(int) should set MAX_STACK_SIZE component for valid values") + void maxStackSizeSetter_shouldSetMaxStackSizeWhenCalled(int max) { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + builder.maxStackSize(max); + + assertEquals(max, builder.maxStackSize()); + assertEquals(max, builder.build().getData(DataComponentTypes.MAX_STACK_SIZE)); + } + + @Test + @DisplayName("maxStackSize() should return null when custom max stack size not set") + void maxStackSizeGetter_shouldReturnNullWhenNotSet() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + assertNull(builder.maxStackSize()); + } + + @Test + @DisplayName("addBannerPattern should add single banner pattern and preserve existing patterns") + void addBannerPattern_shouldAddPatternAndPreserveExistingWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + Pattern pattern1 = new Pattern(DyeColor.RED, PatternType.STRIPE_DOWNRIGHT); + Pattern pattern2 = new Pattern(DyeColor.BLUE, PatternType.CIRCLE); + + builder.addBannerPattern(pattern1); + builder.addBannerPattern(pattern2); + + List patterns = builder.bannerPatterns(); + assertEquals(2, patterns.size()); + assertEquals(pattern1, patterns.get(0)); + assertEquals(pattern2, patterns.get(1)); + } + + @Test + @DisplayName("addBannerPatterns should add multiple patterns and preserve existing ones") + void addBannerPatterns_shouldAddMultiplePatternsAndPreserveExistingWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + Pattern existing = new Pattern(DyeColor.RED, PatternType.STRIPE_DOWNRIGHT); + builder.addBannerPattern(existing); + + Pattern p1 = new Pattern(DyeColor.BLUE, PatternType.CROSS); + Pattern p2 = new Pattern(DyeColor.GREEN, PatternType.BRICKS); + + builder.addBannerPatterns(p1, p2); + + List patterns = builder.bannerPatterns(); + assertEquals(3, patterns.size()); + assertEquals(existing, patterns.get(0)); + assertEquals(p1, patterns.get(1)); + assertEquals(p2, patterns.get(2)); + } + + @Test + @DisplayName("bannerPatterns(Pattern...) should overwrite patterns with provided array") + void bannerPatternsVarargs_shouldOverwritePatternsWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + builder.addBannerPattern(new Pattern(DyeColor.RED, PatternType.STRIPE_DOWNRIGHT)); + + Pattern p1 = new Pattern(DyeColor.BLUE, PatternType.CROSS); + Pattern p2 = new Pattern(DyeColor.GREEN, PatternType.BRICKS); + builder.bannerPatterns(p1, p2); + + List patterns = builder.bannerPatterns(); + assertEquals(2, patterns.size()); + assertEquals(p1, patterns.get(0)); + assertEquals(p2, patterns.get(1)); + } + + @Test + @DisplayName("bannerPatterns(List) should overwrite patterns with provided list") + void bannerPatternsList_shouldOverwritePatternsWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + + List list = List.of( + new Pattern(DyeColor.BLACK, PatternType.BORDER), + new Pattern(DyeColor.WHITE, PatternType.CIRCLE) + ); + + builder.bannerPatterns(list); + + List patterns = builder.bannerPatterns(); + assertEquals(list, patterns); + } + + @Test + @DisplayName("resetBannerPatterns should unset BANNER_PATTERNS component and bannerPatterns() should return empty") + void resetBannerPatterns_shouldUnsetComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + builder.addBannerPattern(new Pattern(DyeColor.RED, PatternType.STRIPE_DOWNRIGHT)); + + builder.resetBannerPatterns(); + + assertTrue(builder.bannerPatterns().isEmpty()); + assertFalse(builder.build().hasData(DataComponentTypes.BANNER_PATTERNS)); + } + + @Test + @DisplayName("bannerPatterns() should return empty list when no banner patterns set") + void bannerPatternsGetter_shouldReturnEmptyListWhenNoPatternsSet() { + ItemBuilder builder = ItemBuilder.of(Material.WHITE_BANNER); + assertTrue(builder.bannerPatterns().isEmpty()); + } + + @Test + @DisplayName("dyeColor(Color) should set DYED_COLOR and dyeColor() should return same color") + void dyeColorSetter_shouldSetDyedColorWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.LEATHER_CHESTPLATE); + Color color = Color.fromRGB(255, 0, 0); + + builder.dyeColor(color); + + assertEquals(color, builder.dyeColor()); + + DyedItemColor component = builder.build().getData(DataComponentTypes.DYED_COLOR); + assertNotNull(component); + assertEquals(color, component.color()); + } + + @Test + @DisplayName("resetDyeColor should unset DYED_COLOR and dyeColor() should return null") + void resetDyeColor_shouldUnsetComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.LEATHER_CHESTPLATE); + builder.dyeColor(Color.BLUE); + + builder.resetDyeColor(); + + assertNull(builder.dyeColor()); + assertFalse(builder.build().hasData(DataComponentTypes.DYED_COLOR)); + } + + @Test + @DisplayName("dyeColor() should return null when no DYED_COLOR set") + void dyeColorGetter_shouldReturnNullWhenNotSet() { + ItemBuilder builder = ItemBuilder.of(Material.LEATHER_CHESTPLATE); + assertNull(builder.dyeColor()); + } + + @Test + @DisplayName("itemColor should map item material to its DyeColor or default to WHITE for non-colored items") + void itemColor_shouldReturnDyeColorForMaterialWhenCalled() { + ItemBuilder orangeBanner = ItemBuilder.of(Material.ORANGE_BANNER); + ItemBuilder redBanner = ItemBuilder.of(Material.RED_BANNER); + ItemBuilder unknown = ItemBuilder.of(Material.STONE); + + assertEquals(DyeColor.ORANGE, orangeBanner.itemColor()); + assertEquals(DyeColor.RED, redBanner.itemColor()); + assertEquals(DyeColor.WHITE, unknown.itemColor()); + } + + @Test + @DisplayName("component(Valued,T) should set and get arbitrary valued data component") + void componentValued_shouldSetAndGetArbitraryComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + + builder.component(DataComponentTypes.MAX_STACK_SIZE, 42); + + Integer value = builder.component(DataComponentTypes.MAX_STACK_SIZE); + assertEquals(42, value); + assertEquals(42, builder.build().getData(DataComponentTypes.MAX_STACK_SIZE)); + } + + @Test + @DisplayName("component(NonValued) should set presence-only component and resetComponent should unset it") + void componentNonValued_shouldSetPresenceOnlyComponentWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + + builder.component(DataComponentTypes.UNBREAKABLE); + assertTrue(builder.unbreakable()); + + builder.resetComponent(DataComponentTypes.UNBREAKABLE); + assertFalse(builder.unbreakable()); + } + + @Test + @DisplayName("persistentData should store integer value in item PersistentDataContainer using Adventure Key") + void persistentData_shouldStoreIntegerValueInPersistentDataContainerWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Key key = Key.key("niveriatest", "int_flag"); + builder.persistentData(key, PersistentDataType.INTEGER, 123); + + ItemStack item = builder.build(); + Integer stored = item.getPersistentDataContainer().get( + new NamespacedKey("niveriatest", "int_flag"), + PersistentDataType.INTEGER + ); + + assertEquals(123, stored); + } + + @Test + @DisplayName("persistentData should store boolean value in item PersistentDataContainer using Adventure Key") + void persistentData_shouldStoreBooleanValueInPersistentDataContainerWhenCalled() { + ItemBuilder builder = ItemBuilder.of(Material.STONE); + Key key = Key.key("niveriatest", "bool_flag"); + builder.persistentData(key, PersistentDataType.BOOLEAN, true); + + ItemStack item = builder.build(); + Boolean stored = item.getPersistentDataContainer().get( + new NamespacedKey("niveriatest", "bool_flag"), + PersistentDataType.BOOLEAN + ); + + assertTrue(stored); + } + + @Test + @DisplayName("hide should add hidden components to tooltip display when tooltip not already fully hidden") + void hide_shouldAddHiddenComponentsWhenTooltipNotHidden() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + + builder.hide(DataComponentTypes.LORE, DataComponentTypes.ENCHANTMENTS); + + Set hidden = builder.hiddenComponents(); + assertTrue(hidden.contains(DataComponentTypes.LORE)); + assertTrue(hidden.contains(DataComponentTypes.ENCHANTMENTS)); + + TooltipDisplay display = builder.build().getData(DataComponentTypes.TOOLTIP_DISPLAY); + assertNotNull(display); + assertFalse(display.hideTooltip()); + } + + @Test + @DisplayName("hide should not modify hidden components when hideTooltip(true) already set") + void hide_shouldNotModifyHiddenComponentsWhenTooltipAlreadyHidden() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + + builder.hideTooltip(true); + Set before = builder.hiddenComponents(); + + builder.hide(DataComponentTypes.LORE); + + Set after = builder.hiddenComponents(); + assertEquals(before, after); + assertTrue(builder.hideTooltip()); + } + + @Test + @DisplayName("hiddenComponents should return empty set when TOOLTIP_DISPLAY is not present") + void hiddenComponents_shouldReturnEmptySetWhenTooltipDisplayMissing() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + assertTrue(builder.hiddenComponents().isEmpty()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + @DisplayName("hideTooltip(boolean) should set hideTooltip flag and hideTooltip() should reflect it") + void hideTooltipSetter_shouldSetHideTooltipFlagWhenCalled(boolean hide) { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + + builder.hideTooltip(hide); + + assertEquals(hide, builder.hideTooltip()); + + TooltipDisplay display = builder.build().getData(DataComponentTypes.TOOLTIP_DISPLAY); + assertNotNull(display); + assertEquals(hide, display.hideTooltip()); + } + + @Test + @DisplayName("hideTooltip() should return false when TOOLTIP_DISPLAY is not present") + void hideTooltipGetter_shouldReturnFalseWhenTooltipDisplayMissing() { + ItemBuilder builder = ItemBuilder.of(Material.DIAMOND_SWORD); + assertFalse(builder.hideTooltip()); + } + } +} diff --git a/src/test/java/fr/kikoplugins/kikoapi/utils/StringUtilsTest.java b/src/test/java/fr/kikoplugins/kikoapi/utils/StringUtilsTest.java index 4c9c921..7aef1ae 100644 --- a/src/test/java/fr/kikoplugins/kikoapi/utils/StringUtilsTest.java +++ b/src/test/java/fr/kikoplugins/kikoapi/utils/StringUtilsTest.java @@ -1,11 +1,12 @@ package fr.kikoplugins.kikoapi.utils; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class StringUtilsTest { - @Test + @DisplayName("Test compareSemVer method") void testCompareSemVer() { // Equal versions Assertions.assertEquals(0, StringUtils.compareSemVer("1.2.3", "1.2.3")); @@ -22,14 +23,15 @@ void testCompareSemVer() { } @Test + @DisplayName("Test compareSemVer with null inputs") void testCompareSemVerNullThrows() { Assertions.assertThrows(NullPointerException.class, () -> StringUtils.compareSemVer(null, "1.0.0")); Assertions.assertThrows(NullPointerException.class, () -> StringUtils.compareSemVer("1.0.0", null)); } @Test + @DisplayName("Test compareSemVer with invalid format") void testCompareSemVerInvalidFormatThrows() { Assertions.assertThrows(NumberFormatException.class, () -> StringUtils.compareSemVer("1.0.a", "1.0.0")); } - }