From 64dde4e35a90949a0db7af2ab9698190f4641ae5 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:33:20 +0100 Subject: [PATCH 01/23] Start Menu system --- .../fr/kikoplugins/kikoapi/menu/Menu.java | 269 +++ .../kikoplugins/kikoapi/menu/MenuContext.java | 185 +++ .../kikoapi/menu/component/MenuComponent.java | 295 ++++ .../menu/component/container/Paginator.java | 1200 ++++++++++++++ .../kikoapi/menu/component/display/Icon.java | 329 ++++ .../menu/component/display/ProgressBar.java | 568 +++++++ .../menu/component/interactive/Button.java | 897 ++++++++++ .../interactive/DoubleDropButton.java | 693 ++++++++ .../menu/component/interactive/Selector.java | 581 +++++++ .../menu/component/interactive/Toggle.java | 476 ++++++ .../kikoapi/menu/component/layout/Grid.java | 388 +++++ .../component/premade/ConfirmationMenu.java | 146 ++ .../menu/event/KikoInventoryClickEvent.java | 98 ++ .../kikoapi/menu/listeners/MenuListener.java | 34 + .../kikoapi/menu/test/BasicTestMenu.java | 262 +++ .../kikoapi/menu/test/DynamicTestMenu.java | 197 +++ .../kikoapi/menu/test/PaginatedTestMenu.java | 109 ++ .../kikoapi/menu/test/PreviousTestMenu.java | 77 + .../kikoplugins/kikoapi/utils/ColorUtils.java | 32 + .../kikoplugins/kikoapi/utils/Direction.java | 36 + .../kikoapi/utils/ItemBuilder.java | 1450 +++++++++++++++++ 21 files changed, 8322 insertions(+) create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/utils/Direction.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java 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..fff4fd7 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -0,0 +1,269 @@ +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.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.menu(this); + + Component title = this.title(); + this.root = this.root(this.context); + this.inventory = Bukkit.createInventory(this, this.root.height() * 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.menu() != 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 and performs cleanup. + * + * @param event whether this close was triggered by an inventory close event. + * If false, the player's inventory will be closed programmatically. + */ + public void close(boolean event) { + this.root.onRemove(this.context); + + if (!event) + this.player.closeInventory(); + + this.context.close(); + this.onClose(); + } + + /** + * 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.menu() == 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"); + + if (this.componentIDs.containsKey(id)) + throw new IllegalStateException("A component with id '" + id + "' is already registered."); + + 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 title(); + + /** + * 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 root(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. + */ + protected void onClose() { + + } + + /** + * Returns the player associated with this menu. + * + * @return the player who owns this menu + */ + public Player player() { + return player; + } + + /** + * Returns the menu context for this menu. + * + * @return the menu context used for component interaction + */ + public MenuContext context() { + 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 componentByID(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..71094d1 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java @@ -0,0 +1,185 @@ +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) { + 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 menu() { + return menu; + } + + /** + * Returns the player associated with this context's menu. + * + * @return the player who owns the menu + */ + public Player player() { + return this.menu.player(); + } + + /** + * Returns the previous menu in the navigation stack. + * + * @return the previous menu instance, or null if there is none + */ + @Nullable + public Menu previousMenu() { + 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 menu(Menu menu) { + Preconditions.checkNotNull(menu, "menu cannot be null"); + if (this.firstMenuSet) { + this.firstMenuSet = false; + return; + } + + lastMenu(); + + this.menu = menu; + this.wasPreviousMenuCall = false; + } + + /** + * Stores the current menu in the previous menus stack if applicable. + */ + private void lastMenu() { + 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); + } + + /** + * Removes all key-value pairs from the context's data map. + */ + public void clear() { + this.data.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..225904a --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -0,0 +1,295 @@ +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.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; + + /** + * Constructs a new MenuComponent with the specified ID. + * + * @param id the unique identifier for this component, or null for a default ID + */ + protected MenuComponent(@Nullable String id) { + this.id = id; + } + + /** + * 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 items(MenuContext context); + + /** + * 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 abstract IntSet slots(MenuContext context); + + /** + * 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.visible()) + return; + + Int2ObjectMap items = this.items(context); + IntSet slots = this.slots(context); + + for (int slot : slots) { + ItemStack item = items.get(slot); + context.menu().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 position(@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; + } + + /** + * Sets the visibility state of this component. + * + * @param visible true to make the component visible, false to hide it + */ + public void visible(boolean visible) { + this.visible = visible; + } + + /** + * Sets the enabled state of this component. + * + * @param enabled true to enable the component, false to disable it + */ + public void enabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns the unique identifier of this component. + * + * @return the component ID + */ + @Nullable + public String id() { + return id; + } + + /** + * Returns the x-coordinate of this component's position. + * + * @return the x-coordinate (0-based) + */ + @NonNegative + public int x() { + return x; + } + + /** + * Returns the y-coordinate of this component's position. + * + * @return the y-coordinate (0-based) + */ + @NonNegative + public int y() { + return y; + } + + /** + * Returns the width of this component in inventory slots. + * + * @return the component width (must be positive) + */ + @Positive + public abstract int width(); + + /** + * Returns the height of this component in inventory rows. + * + * @return the component height (must be positive) + */ + @Positive + public abstract int 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 slot() { + return y * 9 + x; + } + + /** + * Returns whether this component is currently visible. + * + * @return true if visible, false otherwise + */ + public boolean visible() { + return visible; + } + + /** + * Returns whether this component is currently enabled. + * + * @return true if enabled, false otherwise + */ + public boolean enabled() { + return 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 interactable() { + return this.visible && this.enabled; + } + + protected static class Builder { + protected String id; + + /** + * 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 + */ + @SuppressWarnings("unchecked") + @Contract(value = "_ -> this", mutates = "this") + public T id(String id) { + Preconditions.checkNotNull(id, "id cannot be null"); + + this.id = id; + 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..2539cc8 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -0,0 +1,1200 @@ +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.checkerframework.checker.index.qual.Positive; +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 int width, height; + private final IntList layoutSlots; + private Function firstPageItem, lastPageItem, backItem, nextItem; + @Nullable + private Function offBackItem, offNextItem, offFirstPageItem, offLastPageItem; + private int page; + @Nullable + private ObjectList cachedPageComponents; + + /** + * Constructs a new Paginator with the specified parameters. + * + * @param id the unique identifier for this paginator + * @param components the list of components to paginate + * @param backItem function providing the back button item when enabled + * @param nextItem function providing the next button item when enabled + * @param offBackItem function providing the back button item when disabled + * @param offNextItem function providing the next button item when disabled + * @param firstPageItem function providing the first page button item when enabled + * @param lastPageItem function providing the last page button item when enabled + * @param offFirstPageItem function providing the first page button item when disabled + * @param offLastPageItem function providing the last page button item when disabled + * @param width the width of each page in slots + * @param height the height of each page in rows + * @param page the initial page index (0-based) + */ + private Paginator( + String id, + ObjectList components, + Function backItem, Function nextItem, + Function offBackItem, Function offNextItem, + Function firstPageItem, Function lastPageItem, + Function offFirstPageItem, Function offLastPageItem, + int width, int height, + int page + ) { + super(id); + this.components = components; + this.backItem = backItem; + this.nextItem = nextItem; + this.offBackItem = offBackItem; + this.offNextItem = offNextItem; + this.firstPageItem = firstPageItem; + this.lastPageItem = lastPageItem; + this.offFirstPageItem = offFirstPageItem; + this.offLastPageItem = offLastPageItem; + this.width = width; + this.height = height; + this.page = 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.id(); + if (addedID != null) + context.menu().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.id(); + if (removedID != null) + context.menu().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.x() + col; + int absY = this.y() + row; + + this.layoutSlots.add(MenuComponent.toSlot(absX, absY)); + } + } + } + + @Override + public void position(@NonNegative int x, @NonNegative int y) { + super.position(x, y); + // Position changed, we must re-calculate the absolute slots for the grid + 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.interactable()) + return; + + ObjectList pageComponents = this.currentPageComponents(); + 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.slots(context).contains(event.slot())) { + 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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + ObjectList pageComponents = this.currentPageComponents(); + + 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.position(MenuComponent.toX(slot), MenuComponent.toY(slot)); + items.putAll(component.items(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 slots(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 currentPageComponents() { + if (this.cachedPageComponents != null) + return this.cachedPageComponents; + + int maxItemsPerPage = this.width * this.height; + int totalItems = this.components.size(); + int startIndex = this.page * maxItemsPerPage; + 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 backButton() { + 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.context()); + }) + .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 nextButton() { + return Button.create() + .item(context -> { + if (this.page < this.maxPage()) + 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.maxPage()) + return; + + this.page++; + this.render(event.context()); + }) + .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 firstPageButton() { + return Button.create() + .item(context -> { + if (this.page > 0) + 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.context()); + }) + .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 lastPageButton() { + return Button.create() + .item(context -> { + if (this.page < this.maxPage()) + return this.lastPageItem.apply(context); + + if (this.offLastPageItem != null) + return this.offLastPageItem.apply(context); + + return ItemStack.of(Material.AIR); + }) + .onClick(event -> { + int maxPage = this.maxPage(); + if (this.page >= maxPage) + return; + + this.page = maxPage; + this.render(event.context()); + }) + .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 maxPage() { + 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); + String addedID = component.id(); + if (addedID != null) + context.menu().registerComponentID(addedID, component); + + 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.currentPageComponents(); + 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.position(MenuComponent.toX(targetSlot), MenuComponent.toY(targetSlot)); + + if (!component.slots(context).contains(slot)) + continue; + + this.components.remove(component); + String removedID = component.id(); + if (removedID != null) + context.menu().unregisterComponentID(removedID); + + return this; + } + 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"); + + this.components.remove(component); + String removedID = component.id(); + if (removedID != null) + context.menu().unregisterComponentID(removedID); + 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 i = sorted.length - 1; i >= 0; i--) { + int index = sorted[i]; + if (index >= this.components.size()) + break; // 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; + } + + /** + * Returns the width of this paginator in slots. + * + * @return the paginator width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this paginator in rows. + * + * @return the paginator height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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; + + private int width = 1; + private int height = 1; + + /** + * Adds a component to the paginator. + * + * @param context menu context + * @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(MenuContext context, MenuComponent component) { + Preconditions.checkNotNull(context, "context cannot be null"); + Preconditions.checkNotNull(component, "component cannot be null"); + + this.components.add(component); + return this; + } + + /** + * Adds multiple components to the paginator. + * + * @param context menu context + * @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(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; + } + + /** + * 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; + } + + /** + * Sets the width of the paginator 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the paginator 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the paginator. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured Paginator instance. + * + * @return a new Paginator with the specified configuration + */ + public Paginator build() { + return new Paginator( + this.id, + this.components, + this.backItem, + this.nextItem, + this.offBackItem, + this.offNextItem, + this.firstPageItem, + this.lastPageItem, + this.offFirstPageItem, + this.offLastPageItem, + this.width, + this.height, + this.page + ); + } + } +} \ 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..963ba61 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -0,0 +1,329 @@ +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 it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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.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. + * Icons can span multiple slots with configurable width and height. + */ +@NullMarked +public class Icon extends MenuComponent { + private final int width, height; + private Function item; + @Nullable + private Sound sound; + + /** + * Constructs a new Icon with the specified parameters. + * + * @param id unique identifier for the icon + * @param item function that provides the ItemStack to display + * @param sound sound to play when clicked (may be null for no sound) + * @param width width of the icon in slots + * @param height height of the icon in rows + */ + private Icon( + String id, + Function item, + Sound sound, + int width, int height + ) { + super(id); + this.item = item; + + this.sound = sound; + + this.width = width; + this.height = height; + } + + /** + * 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.interactable()) + return; + + if (this.sound == null) + return; + + context.player().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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + if (!this.visible()) + return items; + + ItemStack baseItem = this.item.apply(context); + int baseSlot = this.slot(); + 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, baseItem); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this icon. + *

+ * Includes all slots within the icon's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * 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; + } + + /** + * Returns the width of this icon in slots. + * + * @return the icon width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this icon in rows. + * + * @return the icon height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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; + + private int width = 1; + private int height = 1; + + /** + * 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; + } + + /** + * Sets the width of the icon 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the icon 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the icon. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured Icon instance. + * + * @return a new Icon with the specified configuration + */ + public Icon build() { + return new Icon( + this.id, + this.item, + this.sound, + this.width, + this.height + ); + } + } +} \ 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..328112a --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java @@ -0,0 +1,568 @@ +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.*; +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.checkerframework.checker.index.qual.Positive; +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 final int width, height; + private Function doneItem, currentItem, notDoneItem; + private Object2DoubleFunction percentage; + + /** + * Constructs a new ProgressBar with the specified parameters. + * + * @param id unique identifier for this progress bar + * @param doneItem function that provides the ItemStack for completed sections + * @param currentItem function that provides the ItemStack for the current progress position + * @param notDoneItem function that provides the ItemStack for incomplete sections + * @param direction direction in which the progress bar fills + * @param percentage function that returns the progress percentage (0.0 to 1.0) + * @param width width of the progress bar in slots + * @param height height of the progress bar in rows + */ + private ProgressBar( + String id, + Function doneItem, Function currentItem, Function notDoneItem, + Direction.Default direction, + Object2DoubleFunction percentage, + int width, int height + ) { + super(id); + this.doneItem = doneItem; + this.currentItem = currentItem; + this.notDoneItem = notDoneItem; + this.direction = direction; + this.percentage = percentage; + this.width = width; + this.height = height; + } + + /** + * 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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(this.width * this.height); + if (!this.visible()) + 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.slot(); + 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); + }; + } + + /** + * Returns the set of slots occupied by this progress bar. + *

+ * Includes all slots within the progress bar's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * 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; + } + + /** + * Returns the width of this progress bar in slots. + * + * @return the progress bar width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this progress bar in rows. + * + * @return the progress bar height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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; + + private int width = 1; + private int height = 1; + + /** + * 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; + } + + /** + * Sets the width of the progress bar 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the progress bar 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the progress bar. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured ProgressBar instance. + * + * @return a new ProgressBar with the specified configuration + */ + public ProgressBar build() { + return new ProgressBar( + this.id, + this.doneItem, + this.currentItem, + this.notDoneItem, + this.direction, + this.percentage, + this.width, + this.height + ); + } + } +} \ 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..e716a35 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -0,0 +1,897 @@ +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.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +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.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.List; +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 final int width, height; + private Function item; + @Nullable + private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDrop; + @Nullable + private Sound sound; + @Nullable + private Function> animationFrames; + private int animationInterval; + private boolean stopAnimationOnHide; + @Nullable + private BukkitTask animationTask; + private int currentFrame; + @Nullable + private Function dynamicItem; + private int updateInterval; + private boolean stopUpdatesOnHide; + @Nullable + private BukkitTask updateTask; + + /** + * Constructs a new Button with the specified parameters. + * + * @param id unique identifier for the button + * @param item function that provides the static ItemStack + * @param onClick general click handler for mouse clicks + * @param onLeftClick handler for left clicks + * @param onRightClick handler for right clicks + * @param onShiftLeftClick handler for shift+left clicks + * @param onShiftRightClick handler for shift+right clicks + * @param onDrop handler for drop actions + * @param sound sound to play when clicked (may be null) + * @param animationFrames function providing animation frames (may be null) + * @param animationInterval ticks between animation frames + * @param stopAnimationOnHide whether to stop animation when button is hidden + * @param dynamicItem function providing dynamic content (may be null) + * @param updateInterval ticks between dynamic updates + * @param stopUpdatesOnHide whether to stop updates when button is hidden + * @param width width of the button in slots + * @param height height of the button in rows + */ + private Button( + String id, + Function item, + Consumer onClick, + Consumer onLeftClick, Consumer onRightClick, + Consumer onShiftLeftClick, Consumer onShiftRightClick, + Consumer onDrop, + Sound sound, + Function> animationFrames, int animationInterval, boolean stopAnimationOnHide, + Function dynamicItem, int updateInterval, boolean stopUpdatesOnHide, + int width, int height + ) { + super(id); + this.item = item; + + this.onClick = onClick; + this.onLeftClick = onLeftClick; + this.onRightClick = onRightClick; + this.onShiftLeftClick = onShiftLeftClick; + this.onShiftRightClick = onShiftRightClick; + this.onDrop = onDrop; + + this.sound = sound; + + this.animationFrames = animationFrames; + this.animationInterval = animationInterval; + this.stopAnimationOnHide = stopAnimationOnHide; + + this.dynamicItem = dynamicItem; + this.updateInterval = updateInterval; + this.stopUpdatesOnHide = stopUpdatesOnHide; + + this.width = width; + this.height = height; + } + + /** + * 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.animationFrames != null && this.animationInterval > 0) + this.startAnimation(context); + + if (this.dynamicItem != null && this.updateInterval > 0) + this.startUpdates(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.interactable()) + return; + + Consumer handler = switch (event.getClick()) { + case LEFT, DOUBLE_CLICK -> this.onLeftClick; + case RIGHT -> this.onRightClick; + case SHIFT_LEFT -> this.onShiftLeftClick; + case SHIFT_RIGHT -> this.onShiftRightClick; + case DROP, CONTROL_DROP -> this.onDrop; + default -> null; + }; + + if (handler != null) { + handler.accept(event); + + if (this.sound != null) + context.player().playSound(this.sound, Sound.Emitter.self()); + return; + } + + if (this.onClick != null && event.getClick().isMouseClick()) { + this.onClick.accept(event); + + if (this.sound != null) + context.player().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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + if (!this.visible()) + return items; + + ItemStack baseItem = this.currentItem(context); + int baseSlot = this.slot(); + 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, baseItem); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this button. + *

+ * Includes all slots within the button's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * Starts the animation task that cycles through animation frames. + * + * @param context the menu context + */ + private void startAnimation(MenuContext context) { + this.animationTask = Task.syncRepeat(() -> { + if (!enabled() || (this.stopAnimationOnHide && !visible())) { + 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 (!enabled() || (this.stopUpdatesOnHide && !visible())) { + 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: dynamic item → animation frame → static item + * + * @param context the menu context + * @return the appropriate ItemStack for the current state + */ + private ItemStack currentItem(MenuContext context) { + if (this.dynamicItem != null) + return this.dynamicItem.apply(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.onClick = 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.onLeftClick = 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.onRightClick = onRightClick; + return this; + } + + /** + * Sets the shift+left click handler. + * + * @param onShiftLeftClick the shift+left click handler + * @return this button for method chaining + * @throws NullPointerException if onShiftLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onShiftLeftClick(Consumer onShiftLeftClick) { + Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); + + this.onShiftLeftClick = onShiftLeftClick; + return this; + } + + /** + * Sets the shift+right click handler. + * + * @param onShiftRightClick the shift+right click handler + * @return this button for method chaining + * @throws NullPointerException if onShiftRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button onShiftRightClick(Consumer onShiftRightClick) { + Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); + + this.onShiftRightClick = onShiftRightClick; + 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.onDrop = 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 function providing dynamic content for this button. + * + * @param dynamicItem function that returns dynamically updating ItemStack + * @return this button for method chaining + * @throws NullPointerException if dynamicItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Button dynamicItem(Function dynamicItem) { + Preconditions.checkNotNull(dynamicItem, "dynamicItem cannot be null"); + + this.dynamicItem = dynamicItem; + 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; + } + + /** + * Returns the width of this button in slots. + * + * @return the button width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this button in rows. + * + * @return the button height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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); + + @Nullable + private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDrop; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + Sound.Source.UI, + 1F, + 1F + ); + + @Nullable + private Function> animationFrames; + private int animationInterval = 20; + private boolean stopAnimationOnHide = true; + + @Nullable + private Function dynamicItem; + private int updateInterval = 20; + private boolean stopUpdatesOnHide = false; + + private int width = 1; + private int height = 1; + + /** + * 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.onClick = 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.onLeftClick = 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.onRightClick = onRightClick; + return this; + } + + /** + * Sets the shift+left click handler. + * + * @param onShiftLeftClick the shift+left click handler + * @return this builder for method chaining + * @throws NullPointerException if onShiftLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onShiftLeftClick(Consumer onShiftLeftClick) { + Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); + + this.onShiftLeftClick = onShiftLeftClick; + return this; + } + + /** + * Sets the shift+right click handler. + * + * @param onShiftRightClick the shift+right click handler + * @return this builder for method chaining + * @throws NullPointerException if onShiftRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onShiftRightClick(Consumer onShiftRightClick) { + Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); + + this.onShiftRightClick = onShiftRightClick; + 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.onDrop = 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 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 function providing dynamic content for this button. + * + * @param dynamicItem function that returns dynamically updating ItemStack + * @return this builder for method chaining + * @throws NullPointerException if dynamicItem is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder dynamicItem(Function dynamicItem) { + Preconditions.checkNotNull(dynamicItem, "dynamicItem cannot be null"); + + this.dynamicItem = dynamicItem; + 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; + } + + /** + * Sets the width of the button 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the button 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the button. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured Button instance. + * + * @return a new Button with the specified configuration + */ + public Button build() { + return new Button( + this.id, + this.item, + this.onClick, + this.onLeftClick, + this.onRightClick, + this.onShiftLeftClick, + this.onShiftRightClick, + this.onDrop, + this.sound, + this.animationFrames, + this.animationInterval, + this.stopAnimationOnHide, + this.dynamicItem, + this.updateInterval, + this.stopUpdatesOnHide, + this.width, + this.height + ); + } + } +} \ 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..1cc761e --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -0,0 +1,693 @@ +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.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +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.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 final int width, height; + private Function item; + private Function dropItem; + @Nullable + private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDoubleDrop; + @Nullable + private Sound sound; + @Nullable + private BukkitTask dropTask; + + /** + * Constructs a new DoubleDropButton with the specified parameters. + * + * @param id unique identifier for the button + * @param item function that provides the normal ItemStack + * @param dropItem function that provides the drop state ItemStack + * @param onClick general click handler for mouse clicks + * @param onLeftClick handler for left clicks + * @param onRightClick handler for right clicks + * @param onShiftLeftClick handler for shift+left clicks + * @param onShiftRightClick handler for shift+right clicks + * @param onDoubleDrop handler for double-drop actions + * @param sound sound to play when clicked (may be null) + * @param width width of the button in slots + * @param height height of the button in rows + */ + private DoubleDropButton( + String id, + Function item, + Function dropItem, + Consumer onClick, + Consumer onLeftClick, Consumer onRightClick, + Consumer onShiftLeftClick, Consumer onShiftRightClick, + Consumer onDoubleDrop, + Sound sound, + int width, int height + ) { + super(id); + this.item = item; + this.dropItem = dropItem; + + this.onClick = onClick; + this.onLeftClick = onLeftClick; + this.onRightClick = onRightClick; + this.onShiftLeftClick = onShiftLeftClick; + this.onShiftRightClick = onShiftRightClick; + this.onDoubleDrop = onDoubleDrop; + + this.sound = sound; + + this.width = width; + this.height = height; + } + + /** + * Creates a new DoubleDropButton builder instance. + * + * @return a new DoubleDropButton.Builder for constructing buttons + */ + @Contract(value = "-> new", pure = true) + public static Builder create() { + return new Builder(); + } + + /** + * Called when this 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 button. + *

+ * The button supports several interaction modes: + * - Drop clicks: First drop enters "drop state", second drop within 3 seconds triggers double-drop + * - Specific click handlers: Left, right, shift+left, shift+right clicks + * - General click handler: Fallback 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.interactable()) + return; + + ClickType click = event.getClick(); + if (click == ClickType.DROP || click == ClickType.CONTROL_DROP) { + handleDropClick(event, context); + return; + } + + Consumer handler = switch (event.getClick()) { + case LEFT, DOUBLE_CLICK -> this.onLeftClick; + case RIGHT -> this.onRightClick; + case SHIFT_LEFT -> this.onShiftLeftClick; + case SHIFT_RIGHT -> this.onShiftRightClick; + default -> null; + }; + + if (handler != null) { + handler.accept(event); + + if (this.sound != null) + context.player().playSound(this.sound, Sound.Emitter.self()); + return; + } + + if (this.onClick != null && click.isMouseClick()) { + this.onClick.accept(event); + + if (this.sound != null) + context.player().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.instance(), 3L, TimeUnit.SECONDS); + } + + if (this.sound != null) + context.player().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 (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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + if (!this.visible()) + return items; + + ItemStack baseItem = this.currentItem(context); + int baseSlot = this.slot(); + 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, baseItem); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this button. + *

+ * Includes all slots within the button's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * 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.onClick = 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.onLeftClick = 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.onRightClick = onRightClick; + return this; + } + + /** + * Sets the shift+left click handler. + * + * @param onShiftLeftClick the shift+left click handler + * @return this double drop button for method chaining + * @throws NullPointerException if onShiftLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onShiftLeftClick(Consumer onShiftLeftClick) { + Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); + + this.onShiftLeftClick = onShiftLeftClick; + return this; + } + + /** + * Sets the shift+right click handler. + * + * @param onShiftRightClick the shift+right click handler + * @return this double drop button for method chaining + * @throws NullPointerException if onShiftRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public DoubleDropButton onShiftRightClick(Consumer onShiftRightClick) { + Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); + + this.onShiftRightClick = onShiftRightClick; + 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; + } + + /** + * Returns the width of this button in slots. + * + * @return the button width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this button in rows. + * + * @return the button height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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 currentItem(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); + + @Nullable + private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDoubleDrop; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + BackwardUtils.UI_SOUND_SOURCE, + 1F, + 1F + ); + + private int width = 1; + private int height = 1; + + /** + * 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.onClick = 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.onLeftClick = 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.onRightClick = onRightClick; + return this; + } + + /** + * Sets the shift+left click handler. + * + * @param onShiftLeftClick the shift+left click handler + * @return this builder for method chaining + * @throws NullPointerException if onShiftLeftClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onShiftLeftClick(Consumer onShiftLeftClick) { + Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); + + this.onShiftLeftClick = onShiftLeftClick; + return this; + } + + /** + * Sets the shift+right click handler. + * + * @param onShiftRightClick the shift+right click handler + * @return this builder for method chaining + * @throws NullPointerException if onShiftRightClick is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onShiftRightClick(Consumer onShiftRightClick) { + Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); + + this.onShiftRightClick = onShiftRightClick; + 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 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 width of the button 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the button 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the button. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured DoubleDropButton instance. + * + * @return a new DoubleDropButton with the specified configuration + */ + public DoubleDropButton build() { + return new DoubleDropButton( + this.id, + this.item, + this.dropItem, + this.onClick, + this.onLeftClick, + this.onRightClick, + this.onShiftLeftClick, + this.onShiftRightClick, + this.onDoubleDrop, + this.sound, + this.width, + this.height + ); + } + } +} \ 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..db680c8 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -0,0 +1,581 @@ +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.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +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.checkerframework.checker.index.qual.Positive; +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; + private final int width, height; + @Nullable + private Function defaultOption; + @Nullable + private Consumer> onSelectionChange; + @Nullable + private Sound sound; + private int currentIndex; + + /** + * Constructs a new Selector with the specified parameters. + * + * @param id unique identifier for this selector + * @param options list of selectable options + * @param defaultOption function to determine default selection based on context + * @param onSelectionChange callback for when selection changes + * @param defaultIndex initial selected index + * @param sound sound to play when clicked (may be null) + * @param width width of the selector in slots + * @param height height of the selector in rows + */ + private Selector( + String id, + ObjectList> options, + Function defaultOption, + Consumer> onSelectionChange, + int defaultIndex, + Sound sound, + int width, int height + ) { + super(id); + this.options = options; + this.defaultOption = defaultOption; + this.onSelectionChange = onSelectionChange; + this.currentIndex = defaultIndex; + + this.sound = sound; + + this.width = width; + this.height = height; + } + + /** + * 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.selection(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.interactable()) + 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.player().playSound(this.sound, Sound.Emitter.self()); + + Option oldOption = this.currentOption(); + int oldIndex = this.currentIndex; + this.currentIndex = Math.floorMod(this.currentIndex + operation, this.options.size()); + Option newOption = this.currentOption(); + + if (this.onSelectionChange == null) + 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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + if (!this.visible()) + return items; + + ItemStack baseItem = this.currentItem(context); + int baseSlot = this.slot(); + 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, baseItem); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this selector. + *

+ * Includes all slots within the selector's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * Sets the current selection to the option with the specified value. + * + * @param value the value to select + */ + private void selection(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 currentOption() { + 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 currentItem(MenuContext context) { + return this.currentOption().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 + * @throws NullPointerException if value is null + */ + @Contract(value = "_ -> this", mutates = "this") + public Selector removeOption(T value) { + Preconditions.checkNotNull(value, "value cannot be null"); + + int removedIndex = -1; + for (int i = 0; i < this.options.size(); i++) { + if (Objects.equals(this.options.get(i).value, value)) { + removedIndex = i; + break; + } + } + + this.options.removeIf(option -> Objects.equals(option.value, value)); + + if (removedIndex >= 0 && 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; + } + + /** + * Returns the width of this selector in slots. + * + * @return the selector width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this selector in rows. + * + * @return the selector height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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"), + BackwardUtils.UI_SOUND_SOURCE, + 1F, + 1F + ); + + private int width = 1; + private int height = 1; + + /** + * 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; + } + + /** + * Sets the width of the selector 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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the selector 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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + this.height = height; + return this; + } + + /** + * Sets both width and height of the selector. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured Selector instance. + * + * @return a new Selector with the specified configuration + */ + public Selector build() { + Preconditions.checkArgument( + this.options.isEmpty() || this.defaultIndex < this.options.size(), + "defaultIndex (%s) must be less than options size (%s)", + this.defaultIndex, this.options.size() + ); + + return new Selector<>( + this.id, + this.options, + this.defaultOption, + this.onSelectionChange, + this.defaultIndex, + this.sound, + this.width, + this.height + ); + } + } +} \ 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..2232c93 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -0,0 +1,476 @@ +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.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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.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 final int width, height; + private Function onItem, offItem; + @Nullable + private Consumer onToggle; + @Nullable + private Sound sound; + private boolean currentState; + + /** + * Constructs a new Toggle with the specified parameters. + * + * @param id unique identifier for this toggle + * @param onItem function that provides the ItemStack when toggle is on + * @param offItem function that provides the ItemStack when toggle is off + * @param currentState initial state of the toggle + * @param sound sound to play when clicked (may be null) + * @param width width of the toggle in slots + * @param height height of the toggle in rows + */ + private Toggle( + String id, + Function onItem, Function offItem, + Consumer onToggle, + Sound sound, + boolean currentState, + int width, int height + ) { + super(id); + this.onItem = onItem; + this.offItem = offItem; + this.onToggle = onToggle; + this.sound = sound; + this.currentState = currentState; + this.width = width; + this.height = height; + } + + /** + * 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.interactable()) + return; + + if (this.sound != null) + context.player().playSound(this.sound, Sound.Emitter.self()); + + this.currentState = !this.currentState; + this.render(context); + + 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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + if (!this.visible()) + return items; + + ItemStack baseItem = this.currentItem(context); + int baseSlot = this.slot(); + 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, baseItem); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this toggle. + *

+ * Includes all slots within the toggle's widthxheight area. + * Returns an empty set if not visible. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(this.width * this.height); + if (!this.visible()) + return slots; + + int baseSlot = this.slot(); + 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; + } + + /** + * 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 currentItem(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 selector 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 selector 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 selector 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 selector 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 selector 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 selector 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 selector for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Toggle currentState(boolean state) { + this.currentState = state; + return this; + } + + /** + * Returns the width of this toggle in slots. + * + * @return the toggle width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this toggle in rows. + * + * @return the toggle height + */ + @Positive + @Override + public int height() { + return this.height; + } + + 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.STONE); + private Function offItem = context -> ItemStack.of(Material.STONE); + + @Nullable + private Consumer onToggle; + + @Nullable + private Sound sound = Sound.sound( + Key.key("minecraft", "ui.button.click"), + BackwardUtils.UI_SOUND_SOURCE, + 1F, + 1F + ); + + private boolean currentState; + + private int width = 1; + private int height = 1; + + /** + * 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; + } + + /** + * Sets the width of the toggle in slots. + * + * @param width the width in slots + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of the toggle in rows. + * + * @param height the height in rows + * @return this builder for method chaining + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of the toggle. + * + * @param width the width in slots + * @param height the height in rows + * @return this builder for method chaining + */ + @Contract(value = "_, _ -> this", mutates = "this") + public Builder 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 this; + } + + /** + * Builds and returns the configured Toggle instance. + * + * @return a new Toggle with the specified configuration + */ + public Toggle build() { + return new Toggle( + this.id, + this.onItem, + this.offItem, + this.onToggle, + this.sound, + this.currentState, + this.width, + this.height + ); + } + } +} \ 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..798497b --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -0,0 +1,388 @@ +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.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +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.checkerframework.checker.index.qual.Positive; +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 int width, height; + + private final ObjectList slotComponents; + + @Nullable + private final ItemStack border; + @Nullable + private final ItemStack fill; + + /** + * Constructs a new Grid with the specified parameters. + * + * @param id unique identifier for this grid + * @param width width of the grid in slots + * @param height height of the grid in rows + * @param slotComponents list of components contained within this grid + * @param border ItemStack to use for border decoration (may be null) + * @param fill ItemStack to use for empty space filling (may be null) + */ + private Grid( + String id, + int width, int height, + ObjectList slotComponents, + ItemStack border, ItemStack fill + ) { + super(id); + this.width = width; + this.height = height; + this.slotComponents = slotComponents; + this.border = border; + this.fill = 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 if the grid is visible. + * + * @param context the menu context + */ + @Override + public void onAdd(MenuContext context) { + this.slotComponents.forEach(component -> { + component.onAdd(context); + + String addedID = component.id(); + if (addedID != null) + context.menu().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.id(); + if (removedID != null) + context.menu().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.interactable()) + return; + + for (MenuComponent component : this.slotComponents) { + if (component.slots(context).contains(event.slot())) { + 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 items(MenuContext context) { + Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); + + for (MenuComponent slotComponent : this.slotComponents) + items.putAll(slotComponent.items(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.x(), y + this.y()); + if (items.containsKey(slot)) + continue; + + if (this.border != null && this.border(x + this.x(), y + this.y())) + items.put(slot, this.border); + else if (this.fill != null) + items.put(slot, this.fill); + } + } + + return items; + } + + /** + * Returns the set of slots occupied by this grid. + *

+ * Includes all slots occupied by child components, plus any slots + * that would contain border or fill items. + * + * @param context the menu context + * @return a set of slot indices + */ + @Override + public IntSet slots(MenuContext context) { + IntSet slots = new IntOpenHashSet(); + + int startX = this.x(); + int startY = this.y(); + + for (int y = 0; y < this.height; y++) { + for (int x = 0; x < this.width; x++) { + int slot = toSlot(startX + x, startY + y); + slots.add(slot); + } + } + + return slots; + } + + /** + * Returns the width of this grid in slots. + * + * @return the grid width + */ + @Positive + @Override + public int width() { + return this.width; + } + + /** + * Returns the height of this grid in rows. + * + * @return the grid height + */ + @Positive + @Override + public int height() { + return this.height; + } + + /** + * 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 border(int x, int y) { + return x == this.x() + || x == this.x() + this.width - 1 + || y == this.y() + || y == this.y() + 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<>(); + private int width, height; + @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.position(toX(slot), toY(slot)); + + // Check that the component fits inside the grid + int compX = component.x(); + int compY = component.y(); + int compWidth = component.width(); + int compHeight = component.height(); + + 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; + } + + /** + * Sets the width of this grid. + * + * @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 Builder width(@Positive int width) { + Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); + + this.width = width; + return this; + } + + /** + * Sets the height of this grid. + * + * @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 Builder height(@Positive int height) { + Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); + + this.height = height; + return this; + } + + /** + * Sets both width and height of this grid. + * + * @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 Builder 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 this; + } + + /** + * Builds and returns the configured Grid instance. + * + * @return a new Grid with the specified configuration + */ + public Grid build() { + return new Grid( + this.id, + this.width, this.height, + this.slotComponents, + this.border, this.fill + ); + } + } +} \ 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..ee113c0 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java @@ -0,0 +1,146 @@ +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 yesItem the ItemStack to display for the "yes" button + * @param noItem the ItemStack to display for the "no" button + * @param title the title component displayed at the top of the menu + * @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(player, "player cannot be null"); + Preconditions.checkNotNull(title, "title cannot be null"); + Preconditions.checkNotNull(yesItem, "yesMaterial cannot be null"); + Preconditions.checkNotNull(noItem, "noMaterial 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 title() { + 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 root(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..06d7a29 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java @@ -0,0 +1,98 @@ +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 Niveria 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()); + + 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 player() { + return (Player) getWhoClicked(); + } + + /** + * Returns the slot index that was clicked. + * + * @return the clicked slot index + */ + public int slot() { + return getSlot(); + } + + /** + * Returns the menu context associated with this event. + * + * @return the menu context + */ + public MenuContext context() { + return 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..32a4f16 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java @@ -0,0 +1,34 @@ +package fr.kikoplugins.kikoapi.menu.listeners; + +import fr.kikoplugins.kikoapi.menu.Menu; +import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +public class MenuListener implements Listener { + @EventHandler(ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + Inventory inventory = event.getClickedInventory(); + if (inventory == null || event.getCurrentItem() == null) + return; + + Player player = (Player) event.getWhoClicked(); + + InventoryHolder topHolder = player.getOpenInventory().getTopInventory().getHolder(false); + if (topHolder instanceof Menu) + event.setCancelled(true); + + InventoryHolder holder = inventory.getHolder(false); + if (!(holder instanceof Menu menu)) + return; + + event.setCancelled(true); + + KikoInventoryClickEvent clickEvent = new KikoInventoryClickEvent(event, menu.context()); + menu.handleClick(clickEvent); + } +} 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..da06d3c --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -0,0 +1,262 @@ +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.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.player().sendRichMessage("You clicked the apple!"); + }) + .onDrop(click -> { + click.player().sendRichMessage("Newton"); + click.player().closeInventory(); + }) + .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.RED_WOOL), + ItemStack.of(Material.ORANGE_WOOL), + ItemStack.of(Material.YELLOW_WOOL), + ItemStack.of(Material.LIME_WOOL), + ItemStack.of(Material.BLUE_WOOL), + ItemStack.of(Material.PURPLE_WOOL)) + ) + .animationInterval(5) + .onClick(click -> { + click.player().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() + .dynamicItem(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.player().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() + .dynamicItem(context -> { + Player player = context.menu().player(); + 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.player().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().player().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.player().getGameMode()) + .onSelectionChange(event -> event.context().player().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.player(); + 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 title() { + return Component.text("Test Menu Hehe :3", ColorUtils.primaryColor()); + } + + /** + * 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 root(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..d1707f5 --- /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 title() { + return Component.text("Dynamic Component Test", ColorUtils.primaryColor()); + } + + /** + * 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 root(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.player().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.player().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.player().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.componentByID(PROGRESS_ID); + Icon statusIcon = (Icon) this.componentByID(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..beee038 --- /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 title() { + return Component.text("Paginated Test Menu Hehe :3", ColorUtils.primaryColor()); + } + + /** + * 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 root(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.player().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.player().sendMessage(Component.translatable(event.getCurrentItem().translationKey())); + + MenuContext ctx = event.context(); + paginator.remove(ctx, event.slot()); + paginator.render(ctx); + }) + .build(); + }) + .collect(ObjectArrayList.toList()); + + paginator.addAll(context, materials); + + return Grid.create() + .size(9, 6) + .add(45, paginator.firstPageButton()) + .add(46, paginator.backButton()) + .add(52, paginator.nextButton()) + .add(53, paginator.lastPageButton()) + .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..f6519de --- /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.context().previousMenu(); + if (previous == null) { + event.player().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.player(), event.context()).open(); + }) + .build(); + } + + @Override + protected Component title() { + return Component.text("Menu ID: " + System.identityHashCode(this), ColorUtils.primaryColor()); + } + + @Override + protected MenuComponent root(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/utils/ColorUtils.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java new file mode 100644 index 0000000..3b15a88 --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java @@ -0,0 +1,32 @@ +package fr.kikoplugins.kikoapi.utils; + +import net.kyori.adventure.text.format.TextColor; +import org.jspecify.annotations.NullMarked; + +/** + * Utility class for managing text colors. + */ +@NullMarked +public class ColorUtils { + private ColorUtils() { + throw new IllegalStateException("Utility class"); + } + + /** + * Gets the primary color used in the Niveria API. + * + * @return The primary TextColor. + */ + public static TextColor primaryColor() { + return TextColor.fromHexString("#FC67FA"); + } + + /** + * Gets the secondary color used in the Niveria API. + * + * @return The secondary TextColor. + */ + public static TextColor secondaryColor() { + 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..414d81f --- /dev/null +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java @@ -0,0 +1,1450 @@ +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() { + return itemStack.getData(DataComponentTypes.MAX_DAMAGE) - itemStack.getData(DataComponentTypes.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 itemLore = ItemLore.lore() + .lines(data.lines()) + .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); + 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"); + + List lore = new ArrayList<>(itemStack.getData(DataComponentTypes.LORE).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); + + List lore = new ArrayList<>(itemStack.getData(DataComponentTypes.LORE).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 + )); + } + + /** + * 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 From d2f2aabf907ad0b9c03cc6043f5aa93346e6f9dc Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:39:47 +0100 Subject: [PATCH 02/23] get/set --- .../kikoapi/annotations/Knotty.java | 12 +++ .../kikoapi/annotations/Overexcited.java | 12 +++ .../kikoapi/annotations/Shivery.java | 12 +++ .../kikoapi/annotations/Sleepy.java | 11 +++ .../fr/kikoplugins/kikoapi/menu/Menu.java | 31 ++++---- .../kikoplugins/kikoapi/menu/MenuContext.java | 14 ++-- .../kikoapi/menu/component/MenuComponent.java | 68 ++++++++--------- .../menu/component/container/Paginator.java | 74 +++++++++---------- .../kikoapi/menu/component/display/Icon.java | 20 ++--- .../menu/component/display/ProgressBar.java | 16 ++-- .../menu/component/interactive/Button.java | 45 +++++------ .../interactive/DoubleDropButton.java | 28 +++---- .../menu/component/interactive/Selector.java | 38 +++++----- .../menu/component/interactive/Toggle.java | 26 +++---- .../kikoapi/menu/component/layout/Grid.java | 50 ++++++------- .../component/premade/ConfirmationMenu.java | 4 +- .../kikoapi/menu/listeners/MenuListener.java | 2 +- .../kikoapi/menu/test/BasicTestMenu.java | 12 +-- .../kikoapi/menu/test/DynamicTestMenu.java | 10 +-- .../kikoapi/menu/test/PaginatedTestMenu.java | 16 ++-- .../kikoapi/menu/test/PreviousTestMenu.java | 8 +- .../kikoplugins/kikoapi/utils/ColorUtils.java | 4 +- .../kikoapi/utils/VersionUtils.java | 12 +-- 23 files changed, 282 insertions(+), 243 deletions(-) create mode 100644 src/main/java/fr/kikoplugins/kikoapi/annotations/Knotty.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/annotations/Overexcited.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/annotations/Shivery.java create mode 100644 src/main/java/fr/kikoplugins/kikoapi/annotations/Sleepy.java 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/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java index fff4fd7..aab9899 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -24,10 +24,8 @@ 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; + @Nullable private Inventory inventory; + @Nullable private MenuComponent root; /** * Constructs a new Menu for the specified player. @@ -67,11 +65,11 @@ protected Menu(Player player, MenuContext context) { */ public void open() { this.componentIDs.clear(); - context.menu(this); + context.getMenu(this); - Component title = this.title(); - this.root = this.root(this.context); - this.inventory = Bukkit.createInventory(this, this.root.height() * 9, title); + 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); @@ -89,7 +87,7 @@ public void open() { * existing inventory to refresh the displayed contents.

*/ public void reopen() { - if (context.menu() != this || this.root == null || this.inventory == null) { + if (context.getMenu() != this || this.root == null || this.inventory == null) { this.open(); return; } @@ -109,8 +107,7 @@ public void reopen() { public void close(boolean event) { this.root.onRemove(this.context); - if (!event) - this.player.closeInventory(); + if (!event) this.player.closeInventory(); this.context.close(); this.onClose(); @@ -134,7 +131,7 @@ public void handleClick(KikoInventoryClickEvent event) { // 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.menu() == this) + if (this.context.getMenu() == this) this.root.render(this.context); } @@ -175,7 +172,7 @@ public void unregisterComponentID(String id) { * * @return the title component displayed at the top of the inventory */ - protected abstract Component title(); + protected abstract Component getTitle(); /** * Indicates whether the menu can be returned to using the previous menu system. @@ -198,7 +195,7 @@ protected boolean canGoBackToThisMenu() { * @param context the menu context for component interaction * @return the root component that defines the menu's structure */ - protected abstract MenuComponent root(MenuContext context); + protected abstract MenuComponent getRoot(MenuContext context); /** * Called when the menu is opened. @@ -225,7 +222,7 @@ protected void onClose() { * * @return the player who owns this menu */ - public Player player() { + public Player getPlayer() { return player; } @@ -234,7 +231,7 @@ public Player player() { * * @return the menu context used for component interaction */ - public MenuContext context() { + public MenuContext getContext() { return context; } @@ -246,7 +243,7 @@ public MenuContext context() { * @throws NullPointerException if id is null */ @Nullable - public MenuComponent componentByID(String id) { + public MenuComponent getComponentByID(String id) { Preconditions.checkNotNull(id, "id cannot be null"); return this.componentIDs.get(id); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java index 71094d1..e3aed57 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java @@ -47,7 +47,7 @@ public MenuContext(Menu menu) { * * @return the menu instance */ - public Menu menu() { + public Menu getMenu() { return menu; } @@ -56,8 +56,8 @@ public Menu menu() { * * @return the player who owns the menu */ - public Player player() { - return this.menu.player(); + public Player getPlayer() { + return this.menu.getPlayer(); } /** @@ -66,7 +66,7 @@ public Player player() { * @return the previous menu instance, or null if there is none */ @Nullable - public Menu previousMenu() { + public Menu getPreviousMenu() { if (this.previousMenus.isEmpty()) return null; @@ -89,14 +89,14 @@ public boolean hasPreviousMenu() { * @param menu the new menu to set * @throws NullPointerException if menu is null */ - void menu(Menu menu) { + void getMenu(Menu menu) { Preconditions.checkNotNull(menu, "menu cannot be null"); if (this.firstMenuSet) { this.firstMenuSet = false; return; } - lastMenu(); + storeLastMenu(); this.menu = menu; this.wasPreviousMenuCall = false; @@ -105,7 +105,7 @@ void menu(Menu menu) { /** * Stores the current menu in the previous menus stack if applicable. */ - private void lastMenu() { + private void storeLastMenu() { if (this.wasPreviousMenuCall || !this.menu.canGoBackToThisMenu()) return; diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java index 225904a..7deef98 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -118,7 +118,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @param context the menu context * @return a map from slot indices to ItemStacks */ - public abstract Int2ObjectMap items(MenuContext context); + public abstract Int2ObjectMap getItems(MenuContext context); /** * Returns the set of inventory slot indices that this component occupies. @@ -129,7 +129,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @param context the menu context * @return a set of slot indices */ - public abstract IntSet slots(MenuContext context); + public abstract IntSet getSlots(MenuContext context); /** * Renders this component to the menu's inventory. @@ -143,15 +143,15 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { public void render(MenuContext context) { Preconditions.checkNotNull(context, "context cannot be null"); - if (!this.visible()) + if (!this.isVisible()) return; - Int2ObjectMap items = this.items(context); - IntSet slots = this.slots(context); + Int2ObjectMap items = this.getItems(context); + IntSet slots = this.getSlots(context); for (int slot : slots) { ItemStack item = items.get(slot); - context.menu().getInventory().setItem(slot, item); + context.getMenu().getInventory().setItem(slot, item); } } @@ -162,7 +162,7 @@ public void render(MenuContext context) { * @param y the y-coordinate (0+ for inventory height) * @throws IllegalArgumentException if x or y is negative */ - public void position(@NonNegative int x, @NonNegative int y) { + 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); @@ -170,31 +170,13 @@ public void position(@NonNegative int x, @NonNegative int y) { this.y = y; } - /** - * Sets the visibility state of this component. - * - * @param visible true to make the component visible, false to hide it - */ - public void visible(boolean visible) { - this.visible = visible; - } - - /** - * Sets the enabled state of this component. - * - * @param enabled true to enable the component, false to disable it - */ - public void enabled(boolean enabled) { - this.enabled = enabled; - } - /** * Returns the unique identifier of this component. * * @return the component ID */ @Nullable - public String id() { + public String getID() { return id; } @@ -204,7 +186,7 @@ public String id() { * @return the x-coordinate (0-based) */ @NonNegative - public int x() { + public int getX() { return x; } @@ -214,7 +196,7 @@ public int x() { * @return the y-coordinate (0-based) */ @NonNegative - public int y() { + public int getY() { return y; } @@ -224,7 +206,7 @@ public int y() { * @return the component width (must be positive) */ @Positive - public abstract int width(); + public abstract int getWidth(); /** * Returns the height of this component in inventory rows. @@ -232,7 +214,7 @@ public int y() { * @return the component height (must be positive) */ @Positive - public abstract int height(); + public abstract int getHeight(); /** * Returns the inventory slot index for this component's top-left position. @@ -240,7 +222,7 @@ public int y() { * @return the slot index calculated from x and y coordinates */ @NonNegative - public int slot() { + public int getSlot() { return y * 9 + x; } @@ -249,19 +231,37 @@ public int slot() { * * @return true if visible, false otherwise */ - public boolean visible() { + 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 enabled() { + 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. *

@@ -269,7 +269,7 @@ public boolean enabled() { * * @return true if the component is interactable, false otherwise */ - public boolean interactable() { + public boolean isInteractable() { return this.visible && this.enabled; } 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 index 2539cc8..9016448 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -109,9 +109,9 @@ public void onAdd(MenuContext context) { this.components.forEach(component -> { component.onAdd(context); - String addedID = component.id(); + String addedID = component.getID(); if (addedID != null) - context.menu().registerComponentID(addedID, component); + context.getMenu().registerComponentID(addedID, component); }); } @@ -127,9 +127,9 @@ public void onRemove(MenuContext context) { this.components.forEach(component -> { component.onRemove(context); - String removedID = component.id(); + String removedID = component.getID(); if (removedID != null) - context.menu().unregisterComponentID(removedID); + context.getMenu().unregisterComponentID(removedID); }); } @@ -141,8 +141,8 @@ 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.x() + col; - int absY = this.y() + row; + int absX = this.getX() + col; + int absY = this.getY() + row; this.layoutSlots.add(MenuComponent.toSlot(absX, absY)); } @@ -150,8 +150,8 @@ private void updateLayoutSlots() { } @Override - public void position(@NonNegative int x, @NonNegative int y) { - super.position(x, y); + public void setPosition(@NonNegative int x, @NonNegative int y) { + super.setPosition(x, y); // Position changed, we must re-calculate the absolute slots for the grid this.updateLayoutSlots(); } @@ -167,16 +167,16 @@ public void position(@NonNegative int x, @NonNegative int y) { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; - ObjectList pageComponents = this.currentPageComponents(); + 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.slots(context).contains(event.slot())) { + if (component.getSlots(context).contains(event.slot())) { component.onClick(event, context); return; } @@ -193,9 +193,9 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks for the current page */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - ObjectList pageComponents = this.currentPageComponents(); + ObjectList pageComponents = this.getCurrentPageComponents(); for (int i = 0; i < pageComponents.size(); i++) { if (i >= this.layoutSlots.size()) break; @@ -203,8 +203,8 @@ public Int2ObjectMap items(MenuContext context) { MenuComponent component = pageComponents.get(i); int slot = this.layoutSlots.getInt(i); - component.position(MenuComponent.toX(slot), MenuComponent.toY(slot)); - items.putAll(component.items(context)); + component.setPosition(MenuComponent.toX(slot), MenuComponent.toY(slot)); + items.putAll(component.getItems(context)); } return items; @@ -220,7 +220,7 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of all possible slot indices for this paginator */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { // Return all slots controlled by the paginator grid return new IntOpenHashSet(this.layoutSlots); } @@ -240,7 +240,7 @@ public void render(MenuContext context) { * * @return a list of components for the current page */ - private ObjectList currentPageComponents() { + private ObjectList getCurrentPageComponents() { if (this.cachedPageComponents != null) return this.cachedPageComponents; @@ -268,7 +268,7 @@ private void invalidateCache() { * * @return a Button for going to the previous page */ - public Button backButton() { + public Button getBackButton() { return Button.create() .item(context -> { if (this.page > 0) @@ -297,10 +297,10 @@ public Button backButton() { * * @return a Button for going to the next page */ - public Button nextButton() { + public Button getNextButton() { return Button.create() .item(context -> { - if (this.page < this.maxPage()) + if (this.page < this.getMaxPage()) return this.nextItem.apply(context); if (this.offNextItem != null) @@ -309,7 +309,7 @@ public Button nextButton() { return ItemStack.of(Material.AIR); }) .onClick(event -> { - if (this.page >= this.maxPage()) + if (this.page >= this.getMaxPage()) return; this.page++; @@ -326,7 +326,7 @@ public Button nextButton() { * * @return a Button for going to the first page */ - public Button firstPageButton() { + public Button getFirstPageButton() { return Button.create() .item(context -> { if (this.page > 0) @@ -355,10 +355,10 @@ public Button firstPageButton() { * * @return a Button for going to the last page */ - public Button lastPageButton() { + public Button getLastPageButton() { return Button.create() .item(context -> { - if (this.page < this.maxPage()) + if (this.page < this.getMaxPage()) return this.lastPageItem.apply(context); if (this.offLastPageItem != null) @@ -367,7 +367,7 @@ public Button lastPageButton() { return ItemStack.of(Material.AIR); }) .onClick(event -> { - int maxPage = this.maxPage(); + int maxPage = this.getMaxPage(); if (this.page >= maxPage) return; @@ -382,7 +382,7 @@ public Button lastPageButton() { * * @return the highest valid page index, or -1 if no components exist */ - public int maxPage() { + public int getMaxPage() { int maxItemsPerPage = this.width * this.height; int totalItems = this.components.size(); return (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1; @@ -402,9 +402,9 @@ public Paginator add(MenuContext context, MenuComponent component) { Preconditions.checkNotNull(component, "component cannot be null"); this.components.add(component); - String addedID = component.id(); + String addedID = component.getID(); if (addedID != null) - context.menu().registerComponentID(addedID, component); + context.getMenu().registerComponentID(addedID, component); return this; } @@ -440,7 +440,7 @@ 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.currentPageComponents(); + ObjectList pageComponents = this.getCurrentPageComponents(); for (int i = 0; i < pageComponents.size(); i++) { if (i >= this.layoutSlots.size()) break; @@ -449,15 +449,15 @@ public Paginator remove(MenuContext context, @NonNegative int slot) { int targetSlot = this.layoutSlots.getInt(i); // Temporarily position to check slots - component.position(MenuComponent.toX(targetSlot), MenuComponent.toY(targetSlot)); + component.setPosition(MenuComponent.toX(targetSlot), MenuComponent.toY(targetSlot)); - if (!component.slots(context).contains(slot)) + if (!component.getSlots(context).contains(slot)) continue; this.components.remove(component); - String removedID = component.id(); + String removedID = component.getID(); if (removedID != null) - context.menu().unregisterComponentID(removedID); + context.getMenu().unregisterComponentID(removedID); return this; } @@ -478,9 +478,9 @@ public Paginator remove(MenuContext context, MenuComponent component) { Preconditions.checkNotNull(component, "component cannot be null"); this.components.remove(component); - String removedID = component.id(); + String removedID = component.getID(); if (removedID != null) - context.menu().unregisterComponentID(removedID); + context.getMenu().unregisterComponentID(removedID); return this; } @@ -802,7 +802,7 @@ public Paginator page(@NonNegative int page) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -813,7 +813,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } 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 index 963ba61..6480c14 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -79,13 +79,13 @@ public static Builder create() { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; if (this.sound == null) return; - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } /** @@ -98,13 +98,13 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.visible()) + if (!this.isVisible()) return items; ItemStack baseItem = this.item.apply(context); - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -127,12 +127,12 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -194,7 +194,7 @@ public Icon sound(@Nullable Sound sound) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -205,7 +205,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } 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 index 328112a..5eecabf 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java @@ -85,9 +85,9 @@ public static Builder create() { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return items; double pct = Math.clamp(this.percentage.applyAsDouble(context), 0, 1); @@ -123,7 +123,7 @@ public Int2ObjectMap items(MenuContext context) { */ private void forEachSlot(BiConsumer consumer) { Traversal t = this.traversal(); - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; int idx = 0; @@ -167,12 +167,12 @@ private Traversal traversal() { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -312,7 +312,7 @@ public ProgressBar percentage(Object2DoubleFunction percentage) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -323,7 +323,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } 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 index e716a35..155612f 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -42,21 +42,16 @@ public class Button extends MenuComponent { private Function item; @Nullable private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDrop; - @Nullable - private Sound sound; - @Nullable - private Function> animationFrames; + @Nullable private Sound sound; + @Nullable private Function> animationFrames; private int animationInterval; private boolean stopAnimationOnHide; - @Nullable - private BukkitTask animationTask; + @Nullable private BukkitTask animationTask; private int currentFrame; - @Nullable - private Function dynamicItem; + @Nullable private Function dynamicItem; private int updateInterval; private boolean stopUpdatesOnHide; - @Nullable - private BukkitTask updateTask; + @Nullable private BukkitTask updateTask; /** * Constructs a new Button with the specified parameters. @@ -166,7 +161,7 @@ public void onRemove(MenuContext context) { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; Consumer handler = switch (event.getClick()) { @@ -182,7 +177,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { handler.accept(event); if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); return; } @@ -190,7 +185,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { this.onClick.accept(event); if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } } @@ -204,13 +199,13 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.visible()) + if (!this.isVisible()) return items; - ItemStack baseItem = this.currentItem(context); - int baseSlot = this.slot(); + ItemStack baseItem = this.getCurrentItem(context); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -233,12 +228,12 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -258,7 +253,7 @@ public IntSet slots(MenuContext context) { */ private void startAnimation(MenuContext context) { this.animationTask = Task.syncRepeat(() -> { - if (!enabled() || (this.stopAnimationOnHide && !visible())) { + if (!isEnabled() || (this.stopAnimationOnHide && !isVisible())) { stopAnimation(); return; } @@ -292,7 +287,7 @@ private void stopAnimation() { */ private void startUpdates(MenuContext context) { this.updateTask = Task.syncRepeat(() -> { - if (!enabled() || (this.stopUpdatesOnHide && !visible())) { + if (!isEnabled() || (this.stopUpdatesOnHide && !isVisible())) { stopUpdates(); return; } @@ -320,7 +315,7 @@ private void stopUpdates() { * @param context the menu context * @return the appropriate ItemStack for the current state */ - private ItemStack currentItem(MenuContext context) { + private ItemStack getCurrentItem(MenuContext context) { if (this.dynamicItem != null) return this.dynamicItem.apply(context); @@ -558,7 +553,7 @@ public Button stopUpdatesOnHide(boolean stopUpdatesOnHide) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -569,7 +564,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } 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 index 1cc761e..1b8f45e 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -126,7 +126,7 @@ public void onRemove(MenuContext context) { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; ClickType click = event.getClick(); @@ -147,7 +147,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { handler.accept(event); if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); return; } @@ -155,7 +155,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { this.onClick.accept(event); if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } } @@ -179,11 +179,11 @@ private void handleDropClick(KikoInventoryClickEvent event, MenuContext context) this.dropTask = Task.syncLater(() -> { this.dropTask = null; render(context); - }, KikoAPI.instance(), 3L, TimeUnit.SECONDS); + }, KikoAPI.getInstance(), 3L, TimeUnit.SECONDS); } if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } /** @@ -196,13 +196,13 @@ private void handleDropClick(KikoInventoryClickEvent event, MenuContext context) * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.visible()) + if (!this.isVisible()) return items; ItemStack baseItem = this.currentItem(context); - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -225,12 +225,12 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -412,7 +412,7 @@ public DoubleDropButton sound(@Nullable Sound sound) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -423,7 +423,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } @@ -450,7 +450,7 @@ public static class Builder extends MenuComponent.Builder { @Nullable private Sound sound = Sound.sound( Key.key("minecraft", "ui.button.click"), - BackwardUtils.UI_SOUND_SOURCE, + Sound.Source.UI, 1F, 1F ); 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 index db680c8..01e27c3 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -103,7 +103,7 @@ public void onAdd(MenuContext context) { return; T appliedDefaultOption = this.defaultOption.apply(context); - this.selection(appliedDefaultOption); + this.setSelection(appliedDefaultOption); } /** @@ -118,7 +118,7 @@ public void onAdd(MenuContext context) { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; int operation = switch (event.getClick()) { @@ -131,12 +131,12 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { return; if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); - Option oldOption = this.currentOption(); + Option oldOption = this.getCurrentOption(); int oldIndex = this.currentIndex; this.currentIndex = Math.floorMod(this.currentIndex + operation, this.options.size()); - Option newOption = this.currentOption(); + Option newOption = this.getCurrentOption(); if (this.onSelectionChange == null) return; @@ -162,13 +162,13 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.visible()) + if (!this.isVisible()) return items; - ItemStack baseItem = this.currentItem(context); - int baseSlot = this.slot(); + ItemStack baseItem = this.getCurrentItem(context); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -191,12 +191,12 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -214,7 +214,7 @@ public IntSet slots(MenuContext context) { * * @param value the value to select */ - private void selection(T value) { + 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; @@ -228,7 +228,7 @@ private void selection(T value) { * * @return the current Option instance */ - private Option currentOption() { + private Option getCurrentOption() { return this.options.get(this.currentIndex); } @@ -238,8 +238,8 @@ private Option currentOption() { * @param context the menu context * @return the ItemStack for the current option */ - private ItemStack currentItem(MenuContext context) { - return this.currentOption().item.apply(context); + private ItemStack getCurrentItem(MenuContext context) { + return this.getCurrentOption().item.apply(context); } /** @@ -352,7 +352,7 @@ public Selector sound(@Nullable Sound sound) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -363,7 +363,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } @@ -410,7 +410,7 @@ public static class Builder extends MenuComponent.Builder> { @Nullable private Sound sound = Sound.sound( Key.key("minecraft", "ui.button.click"), - BackwardUtils.UI_SOUND_SOURCE, + Sound.Source.UI, 1F, 1F ); 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 index 2232c93..5043baf 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -88,11 +88,11 @@ public static Builder create() { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; if (this.sound != null) - context.player().playSound(this.sound, Sound.Emitter.self()); + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); this.currentState = !this.currentState; this.render(context); @@ -113,13 +113,13 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.visible()) + if (!this.isVisible()) return items; - ItemStack baseItem = this.currentItem(context); - int baseSlot = this.slot(); + ItemStack baseItem = this.getCurrentItem(context); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -142,12 +142,12 @@ public Int2ObjectMap items(MenuContext context) { * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(this.width * this.height); - if (!this.visible()) + if (!this.isVisible()) return slots; - int baseSlot = this.slot(); + int baseSlot = this.getSlot(); int rowLength = 9; for (int row = 0; row < this.height; row++) { @@ -166,7 +166,7 @@ public IntSet slots(MenuContext context) { * @param context the menu context * @return the appropriate ItemStack for the current state */ - private ItemStack currentItem(MenuContext context) { + private ItemStack getCurrentItem(MenuContext context) { return currentState ? this.onItem.apply(context) : this.offItem.apply(context); } @@ -276,7 +276,7 @@ public Toggle currentState(boolean state) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -287,7 +287,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } @@ -308,7 +308,7 @@ public static class Builder extends MenuComponent.Builder { @Nullable private Sound sound = Sound.sound( Key.key("minecraft", "ui.button.click"), - BackwardUtils.UI_SOUND_SOURCE, + Sound.Source.UI, 1F, 1F ); 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 index 798497b..d829650 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -82,9 +82,9 @@ public void onAdd(MenuContext context) { this.slotComponents.forEach(component -> { component.onAdd(context); - String addedID = component.id(); + String addedID = component.getID(); if (addedID != null) - context.menu().registerComponentID(addedID, component); + context.getMenu().registerComponentID(addedID, component); }); } @@ -100,9 +100,9 @@ public void onRemove(MenuContext context) { this.slotComponents.forEach(component -> { component.onRemove(context); - String removedID = component.id(); + String removedID = component.getID(); if (removedID != null) - context.menu().unregisterComponentID(removedID); + context.getMenu().unregisterComponentID(removedID); }); } @@ -118,11 +118,11 @@ public void onRemove(MenuContext context) { */ @Override public void onClick(KikoInventoryClickEvent event, MenuContext context) { - if (!this.interactable()) + if (!this.isInteractable()) return; for (MenuComponent component : this.slotComponents) { - if (component.slots(context).contains(event.slot())) { + if (component.getSlots(context).contains(event.slot())) { component.onClick(event, context); break; } @@ -140,22 +140,22 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @return a map from slot indices to ItemStacks */ @Override - public Int2ObjectMap items(MenuContext context) { + public Int2ObjectMap getItems(MenuContext context) { Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); for (MenuComponent slotComponent : this.slotComponents) - items.putAll(slotComponent.items(context)); + 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.x(), y + this.y()); + int slot = toSlot(x + this.getX(), y + this.getY()); if (items.containsKey(slot)) continue; - if (this.border != null && this.border(x + this.x(), y + this.y())) + 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); @@ -175,11 +175,11 @@ else if (this.fill != null) * @return a set of slot indices */ @Override - public IntSet slots(MenuContext context) { + public IntSet getSlots(MenuContext context) { IntSet slots = new IntOpenHashSet(); - int startX = this.x(); - int startY = this.y(); + int startX = this.getX(); + int startY = this.getY(); for (int y = 0; y < this.height; y++) { for (int x = 0; x < this.width; x++) { @@ -198,7 +198,7 @@ public IntSet slots(MenuContext context) { */ @Positive @Override - public int width() { + public int getWidth() { return this.width; } @@ -209,7 +209,7 @@ public int width() { */ @Positive @Override - public int height() { + public int getHeight() { return this.height; } @@ -220,11 +220,11 @@ public int height() { * @param y the absolute y-coordinate * @return true if the position is on the grid border, false otherwise */ - private boolean border(int x, int y) { - return x == this.x() - || x == this.x() + this.width - 1 - || y == this.y() - || y == this.y() + this.height - 1; + 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; } /** @@ -252,13 +252,13 @@ 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.position(toX(slot), toY(slot)); + component.setPosition(toX(slot), toY(slot)); // Check that the component fits inside the grid - int compX = component.x(); - int compY = component.y(); - int compWidth = component.width(); - int compHeight = component.height(); + int compX = component.getX(); + int compY = component.getY(); + int compWidth = component.getWidth(); + int compHeight = component.getHeight(); Preconditions.checkArgument( compX >= 0 && compY >= 0 && 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 index ee113c0..28ea2a9 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java @@ -76,7 +76,7 @@ public ConfirmationMenu( * @return the title component */ @Override - protected Component title() { + protected Component getTitle() { return this.title; } @@ -92,7 +92,7 @@ protected Component title() { * @return the root grid component containing all menu elements */ @Override - protected MenuComponent root(MenuContext context) { + protected MenuComponent getRoot(MenuContext context) { Grid.Builder builder = Grid.create() .size(9, 3) .add(11, noButton()) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java index 32a4f16..2ebe9ab 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java @@ -28,7 +28,7 @@ public void onInventoryClick(InventoryClickEvent event) { event.setCancelled(true); - KikoInventoryClickEvent clickEvent = new KikoInventoryClickEvent(event, menu.context()); + KikoInventoryClickEvent clickEvent = new KikoInventoryClickEvent(event, menu.getContext()); menu.handleClick(clickEvent); } } diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java index da06d3c..38c1173 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -109,7 +109,7 @@ private static Button dynamicButton() { private static Button coordinatesDynamicButton() { return Button.create() .dynamicItem(context -> { - Player player = context.menu().player(); + Player player = context.getMenu().getPlayer(); double x = player.getLocation().getX(); double y = player.getLocation().getY(); double z = player.getLocation().getZ(); @@ -162,8 +162,8 @@ private static Selector selector() { .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.player().getGameMode()) - .onSelectionChange(event -> event.context().player().setGameMode(event.newValue())) + .defaultOption(context -> context.getPlayer().getGameMode()) + .onSelectionChange(event -> event.context().getPlayer().setGameMode(event.newValue())) .build(); } @@ -221,8 +221,8 @@ private static ProgressBar downProgressBar() { * @return a colorized title component */ @Override - protected Component title() { - return Component.text("Test Menu Hehe :3", ColorUtils.primaryColor()); + protected Component getTitle() { + return Component.text("Test Menu Hehe :3", ColorUtils.getPrimaryColor()); } /** @@ -244,7 +244,7 @@ protected Component title() { * @return the root grid component containing all test components */ @Override - protected MenuComponent root(MenuContext context) { + protected MenuComponent getRoot(MenuContext context) { return Grid.create() .size(9, 6) .add(0, simpleButton()) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java index d1707f5..4ae172f 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java @@ -43,8 +43,8 @@ public DynamicTestMenu(Player player) { * @return a colorized title component */ @Override - protected Component title() { - return Component.text("Dynamic Component Test", ColorUtils.primaryColor()); + protected Component getTitle() { + return Component.text("Dynamic Component Test", ColorUtils.getPrimaryColor()); } /** @@ -57,7 +57,7 @@ protected Component title() { * @return the root component layout */ @Override - protected MenuComponent root(MenuContext context) { + protected MenuComponent getRoot(MenuContext context) { ProgressBar progressBar = ProgressBar.create() .id(PROGRESS_ID) .doneItem(ItemStack.of(Material.LIME_CONCRETE)) @@ -146,8 +146,8 @@ protected MenuComponent root(MenuContext context) { * - Changes the status icon based on progress level */ private void updateComponents() { - ProgressBar progressBar = (ProgressBar) this.componentByID(PROGRESS_ID); - Icon statusIcon = (Icon) this.componentByID(STATUS_ID); + ProgressBar progressBar = (ProgressBar) this.getComponentByID(PROGRESS_ID); + Icon statusIcon = (Icon) this.getComponentByID(STATUS_ID); progressBar.percentage(this.currentProgress()); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java index beee038..cae37e8 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java @@ -44,8 +44,8 @@ public PaginatedTestMenu(Player player) { * @return a colorized title component */ @Override - protected Component title() { - return Component.text("Paginated Test Menu Hehe :3", ColorUtils.primaryColor()); + protected Component getTitle() { + return Component.text("Paginated Test Menu Hehe :3", ColorUtils.getPrimaryColor()); } /** @@ -63,7 +63,7 @@ protected Component title() { * @return the root grid component containing the paginator and navigation controls */ @Override - protected MenuComponent root(MenuContext context) { + protected MenuComponent getRoot(MenuContext context) { Paginator paginator = Paginator.create() .size(7, 3) .firstPageItem(ItemStack.of(Material.SPECTRAL_ARROW)) @@ -74,7 +74,7 @@ protected MenuComponent root(MenuContext context) { .offLastPageItem(ItemStack.of(Material.ORANGE_DYE)) .build(); - World world = this.player().getWorld(); + World world = this.getPlayer().getWorld(); ObjectList materials = Arrays.stream(Material.values()) .filter(material -> !material.isLegacy()) .filter(Material::isItem) // Remove things like Piston Head @@ -99,10 +99,10 @@ protected MenuComponent root(MenuContext context) { return Grid.create() .size(9, 6) - .add(45, paginator.firstPageButton()) - .add(46, paginator.backButton()) - .add(52, paginator.nextButton()) - .add(53, paginator.lastPageButton()) + .add(45, paginator.getFirstPageButton()) + .add(46, paginator.getBackButton()) + .add(52, paginator.getNextButton()) + .add(53, paginator.getLastPageButton()) .add(10, paginator) .build(); } diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java index f6519de..2b87a05 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java @@ -34,7 +34,7 @@ private static Button previousMenuButton() { .name(Component.text("Go to Previous Menu")) .build()) .onClick(event -> { - Menu previous = event.context().previousMenu(); + Menu previous = event.context().getPreviousMenu(); if (previous == null) { event.player().sendMessage(Component.text("No previous menu found!", NamedTextColor.RED)); return; @@ -57,12 +57,12 @@ private static Button nextMenuButton() { } @Override - protected Component title() { - return Component.text("Menu ID: " + System.identityHashCode(this), ColorUtils.primaryColor()); + protected Component getTitle() { + return Component.text("Menu ID: " + System.identityHashCode(this), ColorUtils.getPrimaryColor()); } @Override - protected MenuComponent root(MenuContext context) { + protected MenuComponent getRoot(MenuContext context) { return Grid.create() .size(9, 3) .add(0, previousMenuButton()) diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java index 3b15a88..af8265a 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java @@ -17,7 +17,7 @@ private ColorUtils() { * * @return The primary TextColor. */ - public static TextColor primaryColor() { + public static TextColor getPrimaryColor() { return TextColor.fromHexString("#FC67FA"); } @@ -26,7 +26,7 @@ public static TextColor primaryColor() { * * @return The secondary TextColor. */ - public static TextColor secondaryColor() { + public static TextColor getSecondaryColor() { return TextColor.fromHexString("#F4C4F3"); } } 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 From 43810fcdcedb3a2f97fd8e2896c7795d1eb44995 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:49:14 +0100 Subject: [PATCH 03/23] get/set 2 --- .../fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java b/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java index 022a4d2..187c2eb 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java +++ b/src/main/java/fr/kikoplugins/kikoapi/updatechecker/UpdateChecker.java @@ -71,7 +71,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) @@ -94,7 +94,7 @@ private void startTask() { } @Nullable - private String latestVersion() { + private String getLatestVersion() { try { HttpRequest request = HttpRequest.newBuilder() .GET() From ccf5a0f6240ae5533fe8abb44525bae609190a0f Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 03:08:47 +0100 Subject: [PATCH 04/23] Add test commands --- .../java/fr/kikoplugins/kikoapi/KikoAPI.java | 13 +++ .../kikoapi/commands/KikoAPICommand.java | 1 + .../kikoapi/commands/KikoAPITestCommand.java | 100 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java 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/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..f8c77cf --- /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.player().sendRichMessage("You clicked Yes"), + event -> event.player().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")) + .executes(ctx -> { + Player player = (Player) ctx.getSource().getExecutor(); + new PreviousTestMenu(player).open(); + + return Command.SINGLE_SUCCESS; + }); + } +} From e9ee9d3cdccd30004353ca214e0f0f900ef0cae2 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:34:00 +0100 Subject: [PATCH 05/23] Improve slightly --- .../kikoapi/menu/component/interactive/Button.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 155612f..c261339 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -40,14 +40,16 @@ public class Button extends MenuComponent { private final int width, height; private Function item; - @Nullable - private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDrop; + @Nullable private Consumer onClick, onDrop; + @Nullable private Consumer onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick; @Nullable private Sound sound; + @Nullable private Function> animationFrames; private int animationInterval; private boolean stopAnimationOnHide; @Nullable private BukkitTask animationTask; private int currentFrame; + @Nullable private Function dynamicItem; private int updateInterval; private boolean stopUpdatesOnHide; From 6cb6a5c8debb02563165ec8eb86e519a33ba351a Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:00:50 +0100 Subject: [PATCH 06/23] get --- .../kikoapi/commands/KikoAPITestCommand.java | 4 ++-- .../menu/component/container/Paginator.java | 10 ++++----- .../menu/component/interactive/Toggle.java | 4 +--- .../kikoapi/menu/component/layout/Grid.java | 2 +- .../menu/event/KikoInventoryClickEvent.java | 15 +++---------- .../kikoapi/menu/test/BasicTestMenu.java | 22 +++++++++---------- .../kikoapi/menu/test/DynamicTestMenu.java | 6 ++--- .../kikoapi/menu/test/PaginatedTestMenu.java | 6 ++--- .../kikoapi/menu/test/PreviousTestMenu.java | 6 ++--- 9 files changed, 32 insertions(+), 43 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java index f8c77cf..4576e86 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java +++ b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java @@ -54,8 +54,8 @@ private static LiteralArgumentBuilder confirmationCommand() 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.player().sendRichMessage("You clicked Yes"), - event -> event.player().sendRichMessage("You clicked No") + event -> event.getPlayer().sendRichMessage("You clicked Yes"), + event -> event.getPlayer().sendRichMessage("You clicked No") ); menu.open(); 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 index 9016448..c748d71 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -176,7 +176,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { break; // Should not happen if page size matches, but safety check MenuComponent component = pageComponents.get(i); - if (component.getSlots(context).contains(event.slot())) { + if (component.getSlots(context).contains(event.getSlot())) { component.onClick(event, context); return; } @@ -284,7 +284,7 @@ public Button getBackButton() { return; this.page--; - this.render(event.context()); + this.render(event.getContext()); }) .build(); } @@ -313,7 +313,7 @@ public Button getNextButton() { return; this.page++; - this.render(event.context()); + this.render(event.getContext()); }) .build(); } @@ -342,7 +342,7 @@ public Button getFirstPageButton() { return; this.page = 0; - this.render(event.context()); + this.render(event.getContext()); }) .build(); } @@ -372,7 +372,7 @@ public Button getLastPageButton() { return; this.page = maxPage; - this.render(event.context()); + this.render(event.getContext()); }) .build(); } 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 index 5043baf..424a797 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -291,9 +291,7 @@ public int getHeight() { return this.height; } - public record ToggleEvent(KikoInventoryClickEvent clickEvent, boolean newState) { - - } + public record ToggleEvent(KikoInventoryClickEvent clickEvent, boolean newState) {} /** * Builder class for constructing Toggle instances with a fluent interface. 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 index d829650..a5c2de1 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -122,7 +122,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { return; for (MenuComponent component : this.slotComponents) { - if (component.getSlots(context).contains(event.slot())) { + if (component.getSlots(context).contains(event.getSlot())) { component.onClick(event, context); break; } diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java index 06d7a29..01a0ea5 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java @@ -74,25 +74,16 @@ public void changeItem(Consumer modifier) { * * @return the player who performed the click action */ - public Player player() { + public Player getPlayer() { return (Player) getWhoClicked(); } - /** - * Returns the slot index that was clicked. - * - * @return the clicked slot index - */ - public int slot() { - return getSlot(); - } - /** * Returns the menu context associated with this event. * * @return the menu context */ - public MenuContext context() { - return context; + public MenuContext getContext() { + return this.context; } } \ No newline at end of file diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java index 38c1173..dc76934 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -49,11 +49,11 @@ private static Button simpleButton() { return Button.create() .item(ItemStack.of(Material.APPLE)) .onClick(click -> { - click.player().sendRichMessage("You clicked the apple!"); + click.getPlayer().sendRichMessage("You clicked the apple!"); }) .onDrop(click -> { - click.player().sendRichMessage("Newton"); - click.player().closeInventory(); + click.getPlayer().sendRichMessage("Newton"); + click.getPlayer().closeInventory(); }) .build(); } @@ -67,16 +67,16 @@ private static Button animatedButton() { return Button.create() .size(2, 2) .animationFrames(context -> - ObjectList.of(ItemStack.of(Material.RED_WOOL), + ObjectList.of(ItemStack.of(Material.SKELETON_SKULL), ItemStack.of(Material.ORANGE_WOOL), - ItemStack.of(Material.YELLOW_WOOL), + ItemStack.of(Material.END_ROD), ItemStack.of(Material.LIME_WOOL), ItemStack.of(Material.BLUE_WOOL), ItemStack.of(Material.PURPLE_WOOL)) ) - .animationInterval(5) + .animationInterval(1) .onClick(click -> { - click.player().sendRichMessage("You clicked the animated button!"); + click.getPlayer().sendRichMessage("You clicked the animated button!"); }) .build(); } @@ -96,7 +96,7 @@ private static Button dynamicButton() { }) .updateInterval(20) .onClick(click -> { - click.player().sendRichMessage("This button shows the current seconds!"); + click.getPlayer().sendRichMessage("This button shows the current seconds!"); }) .build(); } @@ -119,7 +119,7 @@ private static Button coordinatesDynamicButton() { }) .updateInterval(1) .onClick(click -> { - click.player().sendRichMessage("This button shows your current coordinates!"); + click.getPlayer().sendRichMessage("This button shows your current coordinates!"); }) .build(); } @@ -134,7 +134,7 @@ private static Toggle toggle() { .onItem(ItemStack.of(Material.LIME_DYE)) .offItem(ItemStack.of(Material.RED_DYE)) .onToggle(event -> { - event.clickEvent().player().sendRichMessage("" + event.newState()); + event.clickEvent().getPlayer().sendRichMessage("" + event.newState()); }) .build(); } @@ -177,7 +177,7 @@ private static DoubleDropButton doubleDropButton() { .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.player(); + Player player = event.getPlayer(); player.sendRichMessage("You have double-dropped the chest button!"); }) .build(); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java index 4ae172f..6a58c5e 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/DynamicTestMenu.java @@ -91,7 +91,7 @@ protected MenuComponent getRoot(MenuContext context) { this.currentProgress -= 10; updateComponents(); - click.player().sendMessage("Progress: " + currentProgress + "%"); + click.getPlayer().sendMessage("Progress: " + currentProgress + "%"); }) .build(); @@ -113,7 +113,7 @@ protected MenuComponent getRoot(MenuContext context) { this.currentProgress += 10; updateComponents(); - click.player().sendMessage("Progress: " + currentProgress + "%"); + click.getPlayer().sendMessage("Progress: " + currentProgress + "%"); }) .build(); @@ -124,7 +124,7 @@ protected MenuComponent getRoot(MenuContext context) { .onClick(click -> { this.currentProgress = 0; updateComponents(); - click.player().sendMessage("Progress reset to 0%"); + click.getPlayer().sendMessage("Progress reset to 0%"); }) .build(); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java index cae37e8..0456cf3 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PaginatedTestMenu.java @@ -85,10 +85,10 @@ protected MenuComponent getRoot(MenuContext context) { return Button.create() .item(itemStack) .onClick(event -> { - event.player().sendMessage(Component.translatable(event.getCurrentItem().translationKey())); + event.getPlayer().sendMessage(Component.translatable(event.getCurrentItem().translationKey())); - MenuContext ctx = event.context(); - paginator.remove(ctx, event.slot()); + MenuContext ctx = event.getContext(); + paginator.remove(ctx, event.getSlot()); paginator.render(ctx); }) .build(); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java index 2b87a05..3f2f3a4 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/PreviousTestMenu.java @@ -34,9 +34,9 @@ private static Button previousMenuButton() { .name(Component.text("Go to Previous Menu")) .build()) .onClick(event -> { - Menu previous = event.context().getPreviousMenu(); + Menu previous = event.getContext().getPreviousMenu(); if (previous == null) { - event.player().sendMessage(Component.text("No previous menu found!", NamedTextColor.RED)); + event.getPlayer().sendMessage(Component.text("No previous menu found!", NamedTextColor.RED)); return; } @@ -51,7 +51,7 @@ private static Button nextMenuButton() { .name(Component.text("Go to Next Menu")) .build()) .onClick(event -> { - new PreviousTestMenu(event.player(), event.context()).open(); + new PreviousTestMenu(event.getPlayer(), event.getContext()).open(); }) .build(); } From cade0de5d33676d075dacbf0311143df1f734e61 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:14:55 +0100 Subject: [PATCH 07/23] Improve Menu constructors --- .../kikoapi/menu/component/MenuComponent.java | 9 +- .../menu/component/container/Paginator.java | 83 ++++++------------ .../kikoapi/menu/component/display/Icon.java | 33 ++----- .../menu/component/display/ProgressBar.java | 48 +++------- .../menu/component/interactive/Button.java | 87 +++++-------------- .../interactive/DoubleDropButton.java | 72 ++++----------- .../menu/component/interactive/Selector.java | 48 +++------- .../menu/component/interactive/Toggle.java | 50 ++++------- .../kikoapi/menu/component/layout/Grid.java | 43 +++------ 9 files changed, 139 insertions(+), 334 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java index 7deef98..fe4a907 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -8,6 +8,7 @@ import org.bukkit.inventory.ItemStack; import org.checkerframework.checker.index.qual.NonNegative; import org.checkerframework.checker.index.qual.Positive; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -274,7 +275,7 @@ public boolean isInteractable() { } protected static class Builder { - protected String id; + @Nullable protected String id; /** * Sets the ID for this component. @@ -291,5 +292,11 @@ public T id(String id) { this.id = id; return (T) this; } + + @ApiStatus.Internal + @Nullable + public String id() { + return id; + } } } 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 index c748d71..9bbe4b1 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -34,53 +34,36 @@ public class Paginator extends MenuComponent { private final ObjectList components; private final int width, height; private final IntList layoutSlots; - private Function firstPageItem, lastPageItem, backItem, nextItem; - @Nullable - private Function offBackItem, offNextItem, offFirstPageItem, offLastPageItem; + private Function backItem, nextItem; + @Nullable private Function firstPageItem, lastPageItem; + @Nullable private Function offBackItem, offNextItem, offFirstPageItem, offLastPageItem; private int page; - @Nullable - private ObjectList cachedPageComponents; + @Nullable private ObjectList cachedPageComponents; /** - * Constructs a new Paginator with the specified parameters. + * Constructs a new Paginator with the specified configuration. * - * @param id the unique identifier for this paginator - * @param components the list of components to paginate - * @param backItem function providing the back button item when enabled - * @param nextItem function providing the next button item when enabled - * @param offBackItem function providing the back button item when disabled - * @param offNextItem function providing the next button item when disabled - * @param firstPageItem function providing the first page button item when enabled - * @param lastPageItem function providing the last page button item when enabled - * @param offFirstPageItem function providing the first page button item when disabled - * @param offLastPageItem function providing the last page button item when disabled - * @param width the width of each page in slots - * @param height the height of each page in rows - * @param page the initial page index (0-based) + * @param builder the builder containing the paginator configuration */ - private Paginator( - String id, - ObjectList components, - Function backItem, Function nextItem, - Function offBackItem, Function offNextItem, - Function firstPageItem, Function lastPageItem, - Function offFirstPageItem, Function offLastPageItem, - int width, int height, - int page - ) { - super(id); - this.components = components; - this.backItem = backItem; - this.nextItem = nextItem; - this.offBackItem = offBackItem; - this.offNextItem = offNextItem; - this.firstPageItem = firstPageItem; - this.lastPageItem = lastPageItem; - this.offFirstPageItem = offFirstPageItem; - this.offLastPageItem = offLastPageItem; - this.width = width; - this.height = height; - this.page = page; + private Paginator(Builder builder) { + super(builder.id()); + 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.width = builder.width; + this.height = builder.height; + + this.page = builder.page; + this.layoutSlots = new IntArrayList(width * height); // Initial calculation of layout slots @@ -1180,21 +1163,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new Paginator with the specified configuration */ public Paginator build() { - return new Paginator( - this.id, - this.components, - this.backItem, - this.nextItem, - this.offBackItem, - this.offNextItem, - this.firstPageItem, - this.lastPageItem, - this.offFirstPageItem, - this.offLastPageItem, - this.width, - this.height, - this.page - ); + 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 index 6480c14..ffc85b2 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -34,27 +34,18 @@ public class Icon extends MenuComponent { private Sound sound; /** - * Constructs a new Icon with the specified parameters. + * Constructs a new Icon with the specified configuration. * - * @param id unique identifier for the icon - * @param item function that provides the ItemStack to display - * @param sound sound to play when clicked (may be null for no sound) - * @param width width of the icon in slots - * @param height height of the icon in rows + * @param builder the builder containing the icon configuration */ - private Icon( - String id, - Function item, - Sound sound, - int width, int height - ) { - super(id); - this.item = item; + private Icon(Builder builder) { + super(builder.id()); + this.item = builder.item; - this.sound = sound; + this.sound = builder.sound; - this.width = width; - this.height = height; + this.width = builder.width; + this.height = builder.height; } /** @@ -317,13 +308,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new Icon with the specified configuration */ public Icon build() { - return new Icon( - this.id, - this.item, - this.sound, - this.width, - this.height - ); + 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 index 5eecabf..7cf6ab3 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java @@ -35,32 +35,21 @@ public class ProgressBar extends MenuComponent { private Object2DoubleFunction percentage; /** - * Constructs a new ProgressBar with the specified parameters. + * Constructs a new ProgressBar with the specified configuration. * - * @param id unique identifier for this progress bar - * @param doneItem function that provides the ItemStack for completed sections - * @param currentItem function that provides the ItemStack for the current progress position - * @param notDoneItem function that provides the ItemStack for incomplete sections - * @param direction direction in which the progress bar fills - * @param percentage function that returns the progress percentage (0.0 to 1.0) - * @param width width of the progress bar in slots - * @param height height of the progress bar in rows + * @param builder the builder containing the progress bar configuration */ - private ProgressBar( - String id, - Function doneItem, Function currentItem, Function notDoneItem, - Direction.Default direction, - Object2DoubleFunction percentage, - int width, int height - ) { - super(id); - this.doneItem = doneItem; - this.currentItem = currentItem; - this.notDoneItem = notDoneItem; - this.direction = direction; - this.percentage = percentage; - this.width = width; - this.height = height; + private ProgressBar(Builder builder) { + super(builder.id()); + this.doneItem = builder.doneItem; + this.currentItem = builder.currentItem; + this.notDoneItem = builder.notDoneItem; + + this.direction = builder.direction; + this.percentage = builder.percentage; + + this.width = builder.width; + this.height = builder.height; } /** @@ -553,16 +542,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new ProgressBar with the specified configuration */ public ProgressBar build() { - return new ProgressBar( - this.id, - this.doneItem, - this.currentItem, - this.notDoneItem, - this.direction, - this.percentage, - this.width, - this.height - ); + 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 index c261339..b18f39a 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -56,60 +56,33 @@ public class Button extends MenuComponent { @Nullable private BukkitTask updateTask; /** - * Constructs a new Button with the specified parameters. + * Constructs a new Button with the specified configuration. * - * @param id unique identifier for the button - * @param item function that provides the static ItemStack - * @param onClick general click handler for mouse clicks - * @param onLeftClick handler for left clicks - * @param onRightClick handler for right clicks - * @param onShiftLeftClick handler for shift+left clicks - * @param onShiftRightClick handler for shift+right clicks - * @param onDrop handler for drop actions - * @param sound sound to play when clicked (may be null) - * @param animationFrames function providing animation frames (may be null) - * @param animationInterval ticks between animation frames - * @param stopAnimationOnHide whether to stop animation when button is hidden - * @param dynamicItem function providing dynamic content (may be null) - * @param updateInterval ticks between dynamic updates - * @param stopUpdatesOnHide whether to stop updates when button is hidden - * @param width width of the button in slots - * @param height height of the button in rows + * @param builder the builder containing the button configuration */ - private Button( - String id, - Function item, - Consumer onClick, - Consumer onLeftClick, Consumer onRightClick, - Consumer onShiftLeftClick, Consumer onShiftRightClick, - Consumer onDrop, - Sound sound, - Function> animationFrames, int animationInterval, boolean stopAnimationOnHide, - Function dynamicItem, int updateInterval, boolean stopUpdatesOnHide, - int width, int height - ) { - super(id); - this.item = item; + private Button(Builder builder) { + super(builder.id()); + this.item = builder.item; - this.onClick = onClick; - this.onLeftClick = onLeftClick; - this.onRightClick = onRightClick; - this.onShiftLeftClick = onShiftLeftClick; - this.onShiftRightClick = onShiftRightClick; - this.onDrop = onDrop; + this.onClick = builder.onClick; + this.onLeftClick = builder.onLeftClick; + this.onRightClick = builder.onRightClick; + this.onShiftLeftClick = builder.onShiftLeftClick; + this.onShiftRightClick = builder.onShiftRightClick; + this.onDrop = builder.onDrop; - this.sound = sound; + this.sound = builder.sound; - this.animationFrames = animationFrames; - this.animationInterval = animationInterval; - this.stopAnimationOnHide = stopAnimationOnHide; + this.animationFrames = builder.animationFrames; + this.animationInterval = builder.animationInterval; + this.stopAnimationOnHide = builder.stopAnimationOnHide; - this.dynamicItem = dynamicItem; - this.updateInterval = updateInterval; - this.stopUpdatesOnHide = stopUpdatesOnHide; + this.dynamicItem = builder.dynamicItem; + this.updateInterval = builder.updateInterval; + this.stopUpdatesOnHide = builder.stopUpdatesOnHide; - this.width = width; - this.height = height; + this.width = builder.width; + this.height = builder.height; } /** @@ -870,25 +843,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new Button with the specified configuration */ public Button build() { - return new Button( - this.id, - this.item, - this.onClick, - this.onLeftClick, - this.onRightClick, - this.onShiftLeftClick, - this.onShiftRightClick, - this.onDrop, - this.sound, - this.animationFrames, - this.animationInterval, - this.stopAnimationOnHide, - this.dynamicItem, - this.updateInterval, - this.stopUpdatesOnHide, - this.width, - this.height - ); + 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 index 1b8f45e..7adb399 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -46,47 +46,26 @@ public class DoubleDropButton extends MenuComponent { private BukkitTask dropTask; /** - * Constructs a new DoubleDropButton with the specified parameters. + * Constructs a new DoubleDropButton with the specified configuration. * - * @param id unique identifier for the button - * @param item function that provides the normal ItemStack - * @param dropItem function that provides the drop state ItemStack - * @param onClick general click handler for mouse clicks - * @param onLeftClick handler for left clicks - * @param onRightClick handler for right clicks - * @param onShiftLeftClick handler for shift+left clicks - * @param onShiftRightClick handler for shift+right clicks - * @param onDoubleDrop handler for double-drop actions - * @param sound sound to play when clicked (may be null) - * @param width width of the button in slots - * @param height height of the button in rows + * @param builder the builder containing the double drop button configuration */ - private DoubleDropButton( - String id, - Function item, - Function dropItem, - Consumer onClick, - Consumer onLeftClick, Consumer onRightClick, - Consumer onShiftLeftClick, Consumer onShiftRightClick, - Consumer onDoubleDrop, - Sound sound, - int width, int height - ) { - super(id); - this.item = item; - this.dropItem = dropItem; - - this.onClick = onClick; - this.onLeftClick = onLeftClick; - this.onRightClick = onRightClick; - this.onShiftLeftClick = onShiftLeftClick; - this.onShiftRightClick = onShiftRightClick; - this.onDoubleDrop = onDoubleDrop; - - this.sound = sound; - - this.width = width; - this.height = height; + private DoubleDropButton(Builder builder) { + super(builder.id()); + this.item = builder.item; + this.dropItem = builder.dropItem; + + this.onClick = builder.onClick; + this.onLeftClick = builder.onLeftClick; + this.onRightClick = builder.onRightClick; + this.onShiftLeftClick = builder.onShiftLeftClick; + this.onShiftRightClick = builder.onShiftRightClick; + this.onDoubleDrop = builder.onDoubleDrop; + + this.sound = builder.sound; + + this.width = builder.width; + this.height = builder.height; } /** @@ -674,20 +653,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new DoubleDropButton with the specified configuration */ public DoubleDropButton build() { - return new DoubleDropButton( - this.id, - this.item, - this.dropItem, - this.onClick, - this.onLeftClick, - this.onRightClick, - this.onShiftLeftClick, - this.onShiftRightClick, - this.onDoubleDrop, - this.sound, - this.width, - this.height - ); + 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 index 01e27c3..bab7ba5 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -46,36 +46,21 @@ public class Selector extends MenuComponent { private int currentIndex; /** - * Constructs a new Selector with the specified parameters. + * Constructs a new Selector with the specified configuration. * - * @param id unique identifier for this selector - * @param options list of selectable options - * @param defaultOption function to determine default selection based on context - * @param onSelectionChange callback for when selection changes - * @param defaultIndex initial selected index - * @param sound sound to play when clicked (may be null) - * @param width width of the selector in slots - * @param height height of the selector in rows + * @param builder the builder containing the selector configuration */ - private Selector( - String id, - ObjectList> options, - Function defaultOption, - Consumer> onSelectionChange, - int defaultIndex, - Sound sound, - int width, int height - ) { - super(id); - this.options = options; - this.defaultOption = defaultOption; - this.onSelectionChange = onSelectionChange; - this.currentIndex = defaultIndex; + private Selector(Builder builder) { + super(builder.id()); + this.options = new ObjectArrayList<>(builder.options); + this.defaultOption = builder.defaultOption; + this.onSelectionChange = builder.onSelectionChange; + this.currentIndex = builder.defaultIndex; - this.sound = sound; + this.sound = builder.sound; - this.width = width; - this.height = height; + this.width = builder.width; + this.height = builder.height; } /** @@ -566,16 +551,7 @@ public Selector build() { this.defaultIndex, this.options.size() ); - return new Selector<>( - this.id, - this.options, - this.defaultOption, - this.onSelectionChange, - this.defaultIndex, - this.sound, - this.width, - this.height - ); + 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 index 424a797..09802a1 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -39,32 +39,23 @@ public class Toggle extends MenuComponent { private boolean currentState; /** - * Constructs a new Toggle with the specified parameters. + * Constructs a new Toggle with the specified configuration. * - * @param id unique identifier for this toggle - * @param onItem function that provides the ItemStack when toggle is on - * @param offItem function that provides the ItemStack when toggle is off - * @param currentState initial state of the toggle - * @param sound sound to play when clicked (may be null) - * @param width width of the toggle in slots - * @param height height of the toggle in rows + * @param builder the builder containing the toggle configuration */ - private Toggle( - String id, - Function onItem, Function offItem, - Consumer onToggle, - Sound sound, - boolean currentState, - int width, int height - ) { - super(id); - this.onItem = onItem; - this.offItem = offItem; - this.onToggle = onToggle; - this.sound = sound; - this.currentState = currentState; - this.width = width; - this.height = height; + private Toggle(Builder builder) { + super(builder.id()); + this.onItem = builder.onItem; + this.offItem = builder.offItem; + + this.onToggle = builder.onToggle; + + this.sound = builder.sound; + + this.currentState = builder.currentState; + + this.width = builder.width; + this.height = builder.height; } /** @@ -459,16 +450,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new Toggle with the specified configuration */ public Toggle build() { - return new Toggle( - this.id, - this.onItem, - this.offItem, - this.onToggle, - this.sound, - this.currentState, - this.width, - this.height - ); + 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 index a5c2de1..39d8e0e 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -31,33 +31,23 @@ public class Grid extends MenuComponent { private final ObjectList slotComponents; - @Nullable - private final ItemStack border; - @Nullable - private final ItemStack fill; + @Nullable private final ItemStack border; + @Nullable private final ItemStack fill; /** - * Constructs a new Grid with the specified parameters. + * Constructs a new Grid with the specified configuration. * - * @param id unique identifier for this grid - * @param width width of the grid in slots - * @param height height of the grid in rows - * @param slotComponents list of components contained within this grid - * @param border ItemStack to use for border decoration (may be null) - * @param fill ItemStack to use for empty space filling (may be null) + * @param builder the builder containing the builder configuration */ - private Grid( - String id, - int width, int height, - ObjectList slotComponents, - ItemStack border, ItemStack fill - ) { - super(id); - this.width = width; - this.height = height; - this.slotComponents = slotComponents; - this.border = border; - this.fill = fill; + private Grid(Builder builder) { + super(builder.id()); + this.slotComponents = new ObjectArrayList<>(builder.slotComponents); + + this.border = builder.border; + this.fill = builder.fill; + + this.width = builder.width; + this.height = builder.height; } /** @@ -377,12 +367,7 @@ public Builder size(@Positive int width, @Positive int height) { * @return a new Grid with the specified configuration */ public Grid build() { - return new Grid( - this.id, - this.width, this.height, - this.slotComponents, - this.border, this.fill - ); + return new Grid(this); } } } \ No newline at end of file From 025cd0d1b277382a5e8b704384a0d42b57b18a66 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:34:24 +0100 Subject: [PATCH 08/23] Reduce code size & duplication (part 1) --- .../kikoapi/menu/component/MenuComponent.java | 99 ++++++++++++--- .../menu/component/container/Paginator.java | 87 +------------ .../kikoapi/menu/component/display/Icon.java | 114 +----------------- .../menu/component/display/ProgressBar.java | 111 +---------------- .../menu/component/interactive/Button.java | 109 +---------------- .../interactive/DoubleDropButton.java | 110 +---------------- .../menu/component/interactive/Selector.java | 109 +---------------- .../menu/component/interactive/Toggle.java | 107 +--------------- .../kikoapi/menu/component/layout/Grid.java | 107 +--------------- 9 files changed, 95 insertions(+), 858 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java index fe4a907..5639b63 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -4,11 +4,11 @@ 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.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.ApiStatus; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -31,13 +31,17 @@ public abstract class MenuComponent { private int x = 0; private int y = 0; + protected final int width, height; + /** * Constructs a new MenuComponent with the specified ID. * - * @param id the unique identifier for this component, or null for a default ID + * @param builder the builder containing the component configuration */ - protected MenuComponent(@Nullable String id) { - this.id = id; + protected MenuComponent(Builder builder) { + this.id = builder.id; + this.width = builder.width; + this.height = builder.height; } /** @@ -130,7 +134,23 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { * @param context the menu context * @return a set of slot indices */ - public abstract IntSet getSlots(MenuContext context); + 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. @@ -202,20 +222,24 @@ public int getY() { } /** - * Returns the width of this component in inventory slots. + * Returns the width of this component in slots. * - * @return the component width (must be positive) + * @return the component width */ @Positive - public abstract int getWidth(); + public int getWidth() { + return this.width; + } /** - * Returns the height of this component in inventory rows. + * Returns the height of this component in rows. * - * @return the component height (must be positive) + * @return the component height */ @Positive - public abstract int getHeight(); + public int getHeight() { + return this.height; + } /** * Returns the inventory slot index for this component's top-left position. @@ -274,8 +298,10 @@ public boolean isInteractable() { return this.visible && this.enabled; } + @SuppressWarnings("unchecked") protected static class Builder { @Nullable protected String id; + protected int width, height; /** * Sets the ID for this component. @@ -284,7 +310,6 @@ protected static class Builder { * @return this builder for method chaining * @throws NullPointerException if id is null */ - @SuppressWarnings("unchecked") @Contract(value = "_ -> this", mutates = "this") public T id(String id) { Preconditions.checkNotNull(id, "id cannot be null"); @@ -293,10 +318,52 @@ public T id(String id) { return (T) this; } - @ApiStatus.Internal - @Nullable - public String id() { - return id; + /** + * 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 index 9bbe4b1..d6137a9 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -12,7 +12,6 @@ import org.bukkit.Material; 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; @@ -32,7 +31,6 @@ @NullMarked public class Paginator extends MenuComponent { private final ObjectList components; - private final int width, height; private final IntList layoutSlots; private Function backItem, nextItem; @Nullable private Function firstPageItem, lastPageItem; @@ -46,7 +44,7 @@ public class Paginator extends MenuComponent { * @param builder the builder containing the paginator configuration */ private Paginator(Builder builder) { - super(builder.id()); + super(builder); this.components = new ObjectArrayList<>(builder.components); this.backItem = builder.backItem; @@ -59,9 +57,6 @@ private Paginator(Builder builder) { this.offFirstPageItem = builder.offFirstPageItem; this.offLastPageItem = builder.offLastPageItem; - this.width = builder.width; - this.height = builder.height; - this.page = builder.page; this.layoutSlots = new IntArrayList(width * height); @@ -132,13 +127,6 @@ private void updateLayoutSlots() { } } - @Override - public void setPosition(@NonNegative int x, @NonNegative int y) { - super.setPosition(x, y); - // Position changed, we must re-calculate the absolute slots for the grid - this.updateLayoutSlots(); - } - /** * Handles click events within the paginator. *

@@ -778,28 +766,6 @@ public Paginator page(@NonNegative int page) { return this; } - /** - * Returns the width of this paginator in slots. - * - * @return the paginator width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this paginator in rows. - * - * @return the paginator height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Builder class for constructing Paginator instances with a fluent interface. */ @@ -816,9 +782,6 @@ public static class Builder extends MenuComponent.Builder { private int page; - private int width = 1; - private int height = 1; - /** * Adds a component to the paginator. * @@ -1109,54 +1072,6 @@ public Builder page(@NonNegative int page) { return this; } - /** - * Sets the width of the paginator 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the paginator 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the paginator. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured Paginator instance. * 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 index ffc85b2..bb40a6d 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -6,12 +6,9 @@ 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 net.kyori.adventure.sound.Sound; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; -import org.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -24,11 +21,9 @@ * 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. - * Icons can span multiple slots with configurable width and height. */ @NullMarked public class Icon extends MenuComponent { - private final int width, height; private Function item; @Nullable private Sound sound; @@ -39,13 +34,10 @@ public class Icon extends MenuComponent { * @param builder the builder containing the icon configuration */ private Icon(Builder builder) { - super(builder.id()); + super(builder); this.item = builder.item; this.sound = builder.sound; - - this.width = builder.width; - this.height = builder.height; } /** @@ -108,34 +100,6 @@ public Int2ObjectMap getItems(MenuContext context) { return items; } - /** - * Returns the set of slots occupied by this icon. - *

- * Includes all slots within the icon's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Sets the ItemStack to display for this icon. * @@ -178,39 +142,13 @@ public Icon sound(@Nullable Sound sound) { return this; } - /** - * Returns the width of this icon in slots. - * - * @return the icon width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this icon in rows. - * - * @return the icon height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * 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; - - private int width = 1; - private int height = 1; + @Nullable private Sound sound = null; /** * Sets the ItemStack to display for this icon. @@ -254,54 +192,6 @@ public Builder sound(@Nullable Sound sound) { return this; } - /** - * Sets the width of the icon 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the icon 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the icon. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured Icon instance. * 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 index 7cf6ab3..8dec858 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/ProgressBar.java @@ -4,12 +4,13 @@ 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.*; +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.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; @@ -30,7 +31,6 @@ @NullMarked public class ProgressBar extends MenuComponent { private final Direction.Default direction; - private final int width, height; private Function doneItem, currentItem, notDoneItem; private Object2DoubleFunction percentage; @@ -40,7 +40,7 @@ public class ProgressBar extends MenuComponent { * @param builder the builder containing the progress bar configuration */ private ProgressBar(Builder builder) { - super(builder.id()); + super(builder); this.doneItem = builder.doneItem; this.currentItem = builder.currentItem; this.notDoneItem = builder.notDoneItem; @@ -48,8 +48,6 @@ private ProgressBar(Builder builder) { this.direction = builder.direction; this.percentage = builder.percentage; - this.width = builder.width; - this.height = builder.height; } /** @@ -146,34 +144,6 @@ private Traversal traversal() { }; } - /** - * Returns the set of slots occupied by this progress bar. - *

- * Includes all slots within the progress bar's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Sets the ItemStack to display for completed sections. * @@ -294,28 +264,6 @@ public ProgressBar percentage(Object2DoubleFunction percentage) { return this; } - /** - * Returns the width of this progress bar in slots. - * - * @return the progress bar width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this progress bar in rows. - * - * @return the progress bar height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Record representing a range for iteration with start, end, and step values. * @@ -350,9 +298,6 @@ public static class Builder extends MenuComponent.Builder { private Object2DoubleFunction percentage = context -> 0D; - private int width = 1; - private int height = 1; - /** * Sets the ItemStack to display for completed sections. * @@ -488,54 +433,6 @@ public Builder percentage(Object2DoubleFunction percentage) { return this; } - /** - * Sets the width of the progress bar 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the progress bar 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the progress bar. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured ProgressBar instance. * 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 index b18f39a..0f070b9 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -8,8 +8,6 @@ import fr.kikoplugins.kikoapi.utils.Task; 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 it.unimi.dsi.fastutil.objects.ObjectList; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -38,7 +36,6 @@ */ @NullMarked public class Button extends MenuComponent { - private final int width, height; private Function item; @Nullable private Consumer onClick, onDrop; @Nullable private Consumer onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick; @@ -61,7 +58,7 @@ public class Button extends MenuComponent { * @param builder the builder containing the button configuration */ private Button(Builder builder) { - super(builder.id()); + super(builder); this.item = builder.item; this.onClick = builder.onClick; @@ -80,9 +77,6 @@ private Button(Builder builder) { this.dynamicItem = builder.dynamicItem; this.updateInterval = builder.updateInterval; this.stopUpdatesOnHide = builder.stopUpdatesOnHide; - - this.width = builder.width; - this.height = builder.height; } /** @@ -193,34 +187,6 @@ public Int2ObjectMap getItems(MenuContext context) { return items; } - /** - * Returns the set of slots occupied by this button. - *

- * Includes all slots within the button's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Starts the animation task that cycles through animation frames. * @@ -521,28 +487,6 @@ public Button stopUpdatesOnHide(boolean stopUpdatesOnHide) { return this; } - /** - * Returns the width of this button in slots. - * - * @return the button width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this button in rows. - * - * @return the button height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Builder class for constructing Button instances with a fluent interface. */ @@ -570,9 +514,6 @@ public static class Builder extends MenuComponent.Builder { private int updateInterval = 20; private boolean stopUpdatesOnHide = false; - private int width = 1; - private int height = 1; - /** * Sets the ItemStack to display for this button. * @@ -789,54 +730,6 @@ public Builder stopUpdatesOnHide(boolean stopUpdatesOnHide) { return this; } - /** - * Sets the width of the button 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the button 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the button. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured Button instance. * 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 index 7adb399..bad00e9 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -8,15 +8,12 @@ import fr.kikoplugins.kikoapi.utils.Task; 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 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; @@ -35,7 +32,6 @@ */ @NullMarked public class DoubleDropButton extends MenuComponent { - private final int width, height; private Function item; private Function dropItem; @Nullable @@ -51,7 +47,7 @@ public class DoubleDropButton extends MenuComponent { * @param builder the builder containing the double drop button configuration */ private DoubleDropButton(Builder builder) { - super(builder.id()); + super(builder); this.item = builder.item; this.dropItem = builder.dropItem; @@ -63,9 +59,6 @@ private DoubleDropButton(Builder builder) { this.onDoubleDrop = builder.onDoubleDrop; this.sound = builder.sound; - - this.width = builder.width; - this.height = builder.height; } /** @@ -194,34 +187,6 @@ public Int2ObjectMap getItems(MenuContext context) { return items; } - /** - * Returns the set of slots occupied by this button. - *

- * Includes all slots within the button's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Sets the ItemStack to display in normal state. * @@ -384,28 +349,6 @@ public DoubleDropButton sound(@Nullable Sound sound) { return this; } - /** - * Returns the width of this button in slots. - * - * @return the button width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this button in rows. - * - * @return the button height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Gets the ItemStack to display based on the current button state. * @@ -434,9 +377,6 @@ public static class Builder extends MenuComponent.Builder { 1F ); - private int width = 1; - private int height = 1; - /** * Sets the ItemStack to display in normal state. * @@ -599,54 +539,6 @@ public Builder sound(@Nullable Sound sound) { return this; } - /** - * Sets the width of the button 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the button 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the button. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured DoubleDropButton instance. * 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 index bab7ba5..c51258f 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -6,15 +6,12 @@ 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 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.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -36,7 +33,6 @@ @NullMarked public class Selector extends MenuComponent { private final ObjectList> options; - private final int width, height; @Nullable private Function defaultOption; @Nullable @@ -51,16 +47,13 @@ public class Selector extends MenuComponent { * @param builder the builder containing the selector configuration */ private Selector(Builder builder) { - super(builder.id()); + super(builder); this.options = new ObjectArrayList<>(builder.options); this.defaultOption = builder.defaultOption; this.onSelectionChange = builder.onSelectionChange; this.currentIndex = builder.defaultIndex; this.sound = builder.sound; - - this.width = builder.width; - this.height = builder.height; } /** @@ -166,34 +159,6 @@ public Int2ObjectMap getItems(MenuContext context) { return items; } - /** - * Returns the set of slots occupied by this selector. - *

- * Includes all slots within the selector's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Sets the current selection to the option with the specified value. * @@ -330,28 +295,6 @@ public Selector sound(@Nullable Sound sound) { return this; } - /** - * Returns the width of this selector in slots. - * - * @return the selector width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this selector in rows. - * - * @return the selector height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Event record containing information about a selection change. * @@ -400,9 +343,6 @@ public static class Builder extends MenuComponent.Builder> { 1F ); - private int width = 1; - private int height = 1; - /** * Adds an option to the selector with a static ItemStack. * @@ -492,53 +432,6 @@ public Builder sound(@Nullable Sound sound) { return this; } - /** - * Sets the width of the selector 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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the selector 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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - this.height = height; - return this; - } - - /** - * Sets both width and height of the selector. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured Selector instance. * 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 index 09802a1..53d04eb 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -6,13 +6,10 @@ 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 net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; -import org.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -30,7 +27,6 @@ */ @NullMarked public class Toggle extends MenuComponent { - private final int width, height; private Function onItem, offItem; @Nullable private Consumer onToggle; @@ -44,7 +40,7 @@ public class Toggle extends MenuComponent { * @param builder the builder containing the toggle configuration */ private Toggle(Builder builder) { - super(builder.id()); + super(builder); this.onItem = builder.onItem; this.offItem = builder.offItem; @@ -53,9 +49,6 @@ private Toggle(Builder builder) { this.sound = builder.sound; this.currentState = builder.currentState; - - this.width = builder.width; - this.height = builder.height; } /** @@ -123,34 +116,6 @@ public Int2ObjectMap getItems(MenuContext context) { return items; } - /** - * Returns the set of slots occupied by this toggle. - *

- * Includes all slots within the toggle's widthxheight area. - * Returns an empty set if not visible. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - 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; - } - /** * Gets the ItemStack to display based on the current toggle state. * @@ -260,28 +225,6 @@ public Toggle currentState(boolean state) { return this; } - /** - * Returns the width of this toggle in slots. - * - * @return the toggle width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this toggle in rows. - * - * @return the toggle height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - public record ToggleEvent(KikoInventoryClickEvent clickEvent, boolean newState) {} /** @@ -304,9 +247,6 @@ public static class Builder extends MenuComponent.Builder { private boolean currentState; - private int width = 1; - private int height = 1; - /** * Sets the ItemStack to display when the toggle is in the "on" state. * @@ -399,51 +339,6 @@ public Builder currentState(boolean state) { return this; } - /** - * Sets the width of the toggle in slots. - * - * @param width the width in slots - * @return this builder for method chaining - */ - @Contract(value = "_ -> this", mutates = "this") - public Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of the toggle in rows. - * - * @param height the height in rows - * @return this builder for method chaining - */ - @Contract(value = "_ -> this", mutates = "this") - public Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of the toggle. - * - * @param width the width in slots - * @param height the height in rows - * @return this builder for method chaining - */ - @Contract(value = "_, _ -> this", mutates = "this") - public Builder 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 this; - } - /** * Builds and returns the configured Toggle instance. * 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 index 39d8e0e..915b708 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -6,13 +6,10 @@ 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 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.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.Contract; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -27,8 +24,6 @@ */ @NullMarked public class Grid extends MenuComponent { - private final int width, height; - private final ObjectList slotComponents; @Nullable private final ItemStack border; @@ -40,14 +35,11 @@ public class Grid extends MenuComponent { * @param builder the builder containing the builder configuration */ private Grid(Builder builder) { - super(builder.id()); + super(builder); this.slotComponents = new ObjectArrayList<>(builder.slotComponents); this.border = builder.border; this.fill = builder.fill; - - this.width = builder.width; - this.height = builder.height; } /** @@ -155,54 +147,6 @@ else if (this.fill != null) return items; } - /** - * Returns the set of slots occupied by this grid. - *

- * Includes all slots occupied by child components, plus any slots - * that would contain border or fill items. - * - * @param context the menu context - * @return a set of slot indices - */ - @Override - public IntSet getSlots(MenuContext context) { - IntSet slots = new IntOpenHashSet(); - - int startX = this.getX(); - int startY = this.getY(); - - for (int y = 0; y < this.height; y++) { - for (int x = 0; x < this.width; x++) { - int slot = toSlot(startX + x, startY + y); - slots.add(slot); - } - } - - return slots; - } - - /** - * Returns the width of this grid in slots. - * - * @return the grid width - */ - @Positive - @Override - public int getWidth() { - return this.width; - } - - /** - * Returns the height of this grid in rows. - * - * @return the grid height - */ - @Positive - @Override - public int getHeight() { - return this.height; - } - /** * Determines if the specified coordinates represent a border position. * @@ -222,7 +166,6 @@ private boolean isBorder(int x, int y) { */ public static class Builder extends MenuComponent.Builder { private final ObjectList slotComponents = new ObjectArrayList<>(); - private int width, height; @Nullable private ItemStack border; @Nullable @@ -313,54 +256,6 @@ public Builder fill(ItemStack fill) { return this; } - /** - * Sets the width of this grid. - * - * @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 Builder width(@Positive int width) { - Preconditions.checkArgument(width >= 1, "width cannot be less than 1: %s", width); - - this.width = width; - return this; - } - - /** - * Sets the height of this grid. - * - * @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 Builder height(@Positive int height) { - Preconditions.checkArgument(height >= 1, "height cannot be less than 1: %s", height); - - this.height = height; - return this; - } - - /** - * Sets both width and height of this grid. - * - * @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 Builder 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 this; - } - /** * Builds and returns the configured Grid instance. * From d3b0d013f318ad85b14fe07869fed655e2827c60 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:44:42 +0100 Subject: [PATCH 09/23] Reduce code size & duplication (part 2) --- .../kikoapi/menu/component/MenuComponent.java | 29 +++++++++++++++++++ .../kikoapi/menu/component/display/Icon.java | 18 +----------- .../menu/component/interactive/Button.java | 18 +----------- .../interactive/DoubleDropButton.java | 20 ++----------- .../menu/component/interactive/Selector.java | 18 +----------- .../menu/component/interactive/Toggle.java | 18 +----------- 6 files changed, 35 insertions(+), 86 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java index 5639b63..d23eac4 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -4,6 +4,7 @@ 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; @@ -125,6 +126,34 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { */ 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. *

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 index bb40a6d..ed27a1d 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/display/Icon.java @@ -5,7 +5,6 @@ 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 net.kyori.adventure.sound.Sound; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; @@ -82,22 +81,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { */ @Override public Int2ObjectMap getItems(MenuContext context) { - Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.isVisible()) - return items; - - ItemStack baseItem = this.item.apply(context); - 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, baseItem); - } - } - - return items; + return this.getItems(context, this.item.apply(context)); } /** 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 index 0f070b9..9464ba9 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -7,7 +7,6 @@ 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.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectList; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -169,22 +168,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { */ @Override public Int2ObjectMap getItems(MenuContext context) { - Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.isVisible()) - return items; - - ItemStack baseItem = this.getCurrentItem(context); - 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, baseItem); - } - } - - return items; + return this.getItems(context, this.getCurrentItem(context)); } /** 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 index bad00e9..2cf49f8 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -7,7 +7,6 @@ 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.ints.Int2ObjectOpenHashMap; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import org.bukkit.Material; @@ -169,22 +168,7 @@ private void handleDropClick(KikoInventoryClickEvent event, MenuContext context) */ @Override public Int2ObjectMap getItems(MenuContext context) { - Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.isVisible()) - return items; - - ItemStack baseItem = this.currentItem(context); - 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, baseItem); - } - } - - return items; + return this.getItems(context, this.getCurrentItem(context)); } /** @@ -355,7 +339,7 @@ public DoubleDropButton sound(@Nullable Sound sound) { * @param context the menu context * @return the normal item if no drop task is active, otherwise the drop item */ - private ItemStack currentItem(MenuContext context) { + private ItemStack getCurrentItem(MenuContext context) { return this.dropTask == null ? this.item.apply(context) : this.dropItem.apply(context); } 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 index c51258f..dc8f1dd 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -5,7 +5,6 @@ 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 net.kyori.adventure.key.Key; @@ -141,22 +140,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { */ @Override public Int2ObjectMap getItems(MenuContext context) { - Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.isVisible()) - return items; - - ItemStack baseItem = this.getCurrentItem(context); - 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, baseItem); - } - } - - return items; + return this.getItems(context, this.getCurrentItem(context)); } /** 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 index 53d04eb..ed9c285 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -5,7 +5,6 @@ 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 net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import org.bukkit.Material; @@ -98,22 +97,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { */ @Override public Int2ObjectMap getItems(MenuContext context) { - Int2ObjectMap items = new Int2ObjectOpenHashMap<>(); - if (!this.isVisible()) - return items; - - ItemStack baseItem = this.getCurrentItem(context); - 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, baseItem); - } - } - - return items; + return this.getItems(context, this.getCurrentItem(context)); } /** From ef5771db4c44a8dbec5320016e840aa801930c5a Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 03:10:26 +0100 Subject: [PATCH 10/23] Fix invisible components --- .../fr/kikoplugins/kikoapi/menu/component/MenuComponent.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java index d23eac4..1ad55dc 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/MenuComponent.java @@ -330,7 +330,8 @@ public boolean isInteractable() { @SuppressWarnings("unchecked") protected static class Builder { @Nullable protected String id; - protected int width, height; + protected int width = 1; + protected int height = 1; /** * Sets the ID for this component. From 53e2e637035f6fad40749b14f14683eacf94dca3 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 03:26:02 +0100 Subject: [PATCH 11/23] Remove useless dynamicItem methods --- .../menu/component/interactive/Button.java | 47 ++----------------- .../kikoapi/menu/test/BasicTestMenu.java | 6 +-- 2 files changed, 8 insertions(+), 45 deletions(-) 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 index 9464ba9..dc9a6fb 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -46,7 +46,6 @@ public class Button extends MenuComponent { @Nullable private BukkitTask animationTask; private int currentFrame; - @Nullable private Function dynamicItem; private int updateInterval; private boolean stopUpdatesOnHide; @Nullable private BukkitTask updateTask; @@ -73,7 +72,6 @@ private Button(Builder builder) { this.animationInterval = builder.animationInterval; this.stopAnimationOnHide = builder.stopAnimationOnHide; - this.dynamicItem = builder.dynamicItem; this.updateInterval = builder.updateInterval; this.stopUpdatesOnHide = builder.stopUpdatesOnHide; } @@ -97,11 +95,11 @@ public static Builder create() { */ @Override public void onAdd(MenuContext context) { + if (this.updateInterval > 0) + this.startUpdates(context); + if (this.animationFrames != null && this.animationInterval > 0) this.startAnimation(context); - - if (this.dynamicItem != null && this.updateInterval > 0) - this.startUpdates(context); } /** @@ -241,9 +239,6 @@ private void stopUpdates() { * @return the appropriate ItemStack for the current state */ private ItemStack getCurrentItem(MenuContext context) { - if (this.dynamicItem != null) - return this.dynamicItem.apply(context); - if (this.animationFrames != null) { ObjectList frames = this.animationFrames.apply(context); if (frames.isEmpty()) @@ -429,21 +424,6 @@ public Button stopAnimationOnHide(boolean stopAnimationOnHide) { return this; } - /** - * Sets the function providing dynamic content for this button. - * - * @param dynamicItem function that returns dynamically updating ItemStack - * @return this button for method chaining - * @throws NullPointerException if dynamicItem is null - */ - @Contract(value = "_ -> this", mutates = "this") - public Button dynamicItem(Function dynamicItem) { - Preconditions.checkNotNull(dynamicItem, "dynamicItem cannot be null"); - - this.dynamicItem = dynamicItem; - return this; - } - /** * Sets the interval between dynamic content updates in ticks. * @@ -490,12 +470,10 @@ public static class Builder extends MenuComponent.Builder { @Nullable private Function> animationFrames; - private int animationInterval = 20; + private int animationInterval = -1; private boolean stopAnimationOnHide = true; - @Nullable - private Function dynamicItem; - private int updateInterval = 20; + private int updateInterval = -1; private boolean stopUpdatesOnHide = false; /** @@ -672,21 +650,6 @@ public Builder stopAnimationOnHide(boolean stopAnimationOnHide) { return this; } - /** - * Sets the function providing dynamic content for this button. - * - * @param dynamicItem function that returns dynamically updating ItemStack - * @return this builder for method chaining - * @throws NullPointerException if dynamicItem is null - */ - @Contract(value = "_ -> this", mutates = "this") - public Builder dynamicItem(Function dynamicItem) { - Preconditions.checkNotNull(dynamicItem, "dynamicItem cannot be null"); - - this.dynamicItem = dynamicItem; - return this; - } - /** * Sets the interval between dynamic content updates in ticks. * diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java index dc76934..1eff6da 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -88,7 +88,7 @@ private static Button animatedButton() { */ private static Button dynamicButton() { return Button.create() - .dynamicItem(context -> { + .item(context -> { int seconds = (int) (System.currentTimeMillis() / 1000 % 60); return ItemBuilder.of(Material.OAK_SIGN).name( Component.text("Seconds: " + seconds) @@ -108,8 +108,8 @@ private static Button dynamicButton() { */ private static Button coordinatesDynamicButton() { return Button.create() - .dynamicItem(context -> { - Player player = context.getMenu().getPlayer(); + .item(context -> { + Player player = context.getPlayer(); double x = player.getLocation().getX(); double y = player.getLocation().getY(); double z = player.getLocation().getZ(); From aa5de1f0fe9d4a84ea378d78546d167d092cde23 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:52:05 +0100 Subject: [PATCH 12/23] Fix paginator component being in the wrong position --- .../kikoapi/menu/component/container/Paginator.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 index d6137a9..b3e1963 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -127,6 +127,12 @@ private void updateLayoutSlots() { } } + @Override + public void setPosition(@NonNegative int x, @NonNegative int y) { + super.setPosition(x, y); + this.updateLayoutSlots(); + } + /** * Handles click events within the paginator. *

From 4add8090885f4e67fb020e871cffaf0e489312ed Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:15:29 +0100 Subject: [PATCH 13/23] reviews --- .../kikoapi/commands/KikoAPITestCommand.java | 2 +- .../fr/kikoplugins/kikoapi/menu/Menu.java | 7 ++- .../kikoplugins/kikoapi/menu/MenuContext.java | 8 ++- .../menu/component/container/Paginator.java | 20 +++---- .../menu/component/interactive/Selector.java | 13 +++-- .../menu/event/KikoInventoryClickEvent.java | 4 +- .../kikoplugins/kikoapi/utils/ColorUtils.java | 6 +- .../kikoapi/utils/ItemBuilder.java | 57 ++++++++++++++++--- 8 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java index 4576e86..561341b 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java +++ b/src/main/java/fr/kikoplugins/kikoapi/commands/KikoAPITestCommand.java @@ -89,7 +89,7 @@ private static LiteralArgumentBuilder paginatorCommand() { private static LiteralArgumentBuilder previousCommand() { return Commands.literal("previous") - .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.previous")) + .requires(css -> CommandUtils.defaultRequirements(css, "kikoapi.command.kikoapi.test.previous", true)) .executes(ctx -> { Player player = (Player) ctx.getSource().getExecutor(); new PreviousTestMenu(player).open(); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java index aab9899..6a517ff 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -105,9 +105,12 @@ public void reopen() { * If false, the player's inventory will be closed programmatically. */ public void close(boolean event) { - this.root.onRemove(this.context); + // 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 (!event) this.player.closeInventory(); + if (!event) + this.player.closeInventory(); this.context.close(); this.onClose(); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java index e3aed57..c064ed4 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java @@ -36,6 +36,8 @@ public class MenuContext { * @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<>()); @@ -167,10 +169,14 @@ public void remove(String key) { } /** - * Removes all key-value pairs from the context's data map. + * 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(); } /** 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 index b3e1963..13f1e57 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -306,7 +306,7 @@ public Button getNextButton() { public Button getFirstPageButton() { return Button.create() .item(context -> { - if (this.page > 0) + if (this.page > 0 && this.firstPageItem != null) return this.firstPageItem.apply(context); if (this.offFirstPageItem != null) @@ -335,7 +335,7 @@ public Button getFirstPageButton() { public Button getLastPageButton() { return Button.create() .item(context -> { - if (this.page < this.getMaxPage()) + if (this.page < this.getMaxPage() && this.lastPageItem != null) return this.lastPageItem.apply(context); if (this.offLastPageItem != null) @@ -479,7 +479,7 @@ public Paginator removeAll(MenuContext context, IntSet indexes) { for (int i = sorted.length - 1; i >= 0; i--) { int index = sorted[i]; if (index >= this.components.size()) - break; // The next indexes will always be bigger + continue; // The next indexes will always be bigger MenuComponent component = this.components.get(index); this.remove(context, component); @@ -791,14 +791,12 @@ public static class Builder extends MenuComponent.Builder { /** * Adds a component to the paginator. * - * @param context menu context * @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(MenuContext context, MenuComponent component) { - Preconditions.checkNotNull(context, "context cannot be null"); + @Contract(value = "_ -> this", mutates = "this") + public Builder add(MenuComponent component) { Preconditions.checkNotNull(component, "component cannot be null"); this.components.add(component); @@ -808,18 +806,16 @@ public Builder add(MenuContext context, MenuComponent component) { /** * Adds multiple components to the paginator. * - * @param context menu context * @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(MenuContext context, ObjectList components) { - Preconditions.checkNotNull(context, "context cannot be null"); + @Contract(value = "_ -> this", mutates = "this") + public Builder addAll(ObjectList components) { Preconditions.checkNotNull(components, "components cannot be null"); for (MenuComponent component : components) - this.add(context, component); + this.add(component); return this; } 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 index dc8f1dd..05bf520 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -115,7 +115,7 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { this.currentIndex = Math.floorMod(this.currentIndex + operation, this.options.size()); Option newOption = this.getCurrentOption(); - if (this.onSelectionChange == null) + if (this.onSelectionChange == null || oldIndex == this.currentIndex) return; SelectionChangeEvent selectionChangeEvent = new SelectionChangeEvent<>( @@ -216,9 +216,7 @@ public Selector addOption(Function item, @Nullable T * @throws NullPointerException if value is null */ @Contract(value = "_ -> this", mutates = "this") - public Selector removeOption(T value) { - Preconditions.checkNotNull(value, "value cannot be null"); - + 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)) { @@ -423,7 +421,12 @@ public Builder sound(@Nullable Sound sound) { */ public Selector build() { Preconditions.checkArgument( - this.options.isEmpty() || this.defaultIndex < this.options.size(), + !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() ); diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java index 01a0ea5..1ca788e 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/event/KikoInventoryClickEvent.java @@ -12,7 +12,7 @@ import java.util.function.Consumer; /** - * A specialized inventory click event for Niveria menu interactions. + * 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 @@ -31,7 +31,7 @@ public class KikoInventoryClickEvent extends InventoryClickEvent { */ @SuppressWarnings("UnstableApiUsage") public KikoInventoryClickEvent(InventoryClickEvent event, MenuContext context) { - super(event.getView(), event.getSlotType(), event.getSlot(), event.getClick(), event.getAction()); + super(event.getView(), event.getSlotType(), event.getSlot(), event.getClick(), event.getAction(), event.getHotbarButton()); Preconditions.checkNotNull(context, "context cannot be null"); diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java index af8265a..9de26dd 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ColorUtils.java @@ -6,6 +6,8 @@ /** * Utility class for managing text colors. */ +// Hex values are hardcoded +@SuppressWarnings("DataFlowIssue") @NullMarked public class ColorUtils { private ColorUtils() { @@ -13,7 +15,7 @@ private ColorUtils() { } /** - * Gets the primary color used in the Niveria API. + * Gets the primary color used in the KikoAPI. * * @return The primary TextColor. */ @@ -22,7 +24,7 @@ public static TextColor getPrimaryColor() { } /** - * Gets the secondary color used in the Niveria API. + * Gets the secondary color used in the KikoAPI. * * @return The secondary TextColor. */ diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java index 414d81f..e282bbe 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java @@ -268,7 +268,13 @@ public ItemBuilder durability(@NonNegative int durability) { */ @NonNegative public int durability() { - return itemStack.getData(DataComponentTypes.MAX_DAMAGE) - itemStack.getData(DataComponentTypes.DAMAGE); + 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; } /** @@ -711,8 +717,12 @@ public ItemBuilder addLoreLine(Component line) { Preconditions.checkNotNull(line, "line cannot be null"); ItemLore data = itemStack.getData(DataComponentTypes.LORE); - ItemLore itemLore = ItemLore.lore() - .lines(data.lines()) + + ItemLore.Builder builder = ItemLore.lore(); + if (data != null) + builder.lines(data.lines()); + + ItemLore itemLore = builder .addLine(line.decorationIfAbsent(TextDecoration.ITALIC, TextDecoration.State.FALSE)) .build(); @@ -733,6 +743,9 @@ public ItemBuilder setLoreLine(Component line, @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()); if (lore.size() <= index) throw new IndexOutOfBoundsException("Lore index out of bounds: " + index + ", size: " + lore.size()); @@ -770,7 +783,11 @@ public ItemBuilder removeLoreLine(Component line) { public ItemBuilder removeLoreLine(@NonNegative int index) { Preconditions.checkArgument(index >= 0, "index cannot be negative: %s", index); - List lore = new ArrayList<>(itemStack.getData(DataComponentTypes.LORE).lines()); + 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)); @@ -830,7 +847,13 @@ public ItemBuilder addAttributeModifier(Attribute attribute, AttributeModifier m ItemAttributeModifiers data = itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS); if (data != null) - data.modifiers().forEach(attModifier -> itemAttributeModifiers.addModifier(attModifier.attribute(), attModifier.modifier(), attModifier.getGroup())); + data.modifiers().forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); return this; @@ -848,7 +871,13 @@ public ItemBuilder addAttributeModifiers(Map attri 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())); + itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); attributeModifiers.forEach(itemAttributeModifiers::addModifier); @@ -909,7 +938,13 @@ public ItemBuilder removeAttributeModifier(Attribute attribute) { itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().stream() .filter(attModifier -> !attModifier.attribute().equals(attribute)) - .forEach(attModifier -> itemAttributeModifiers.addModifier(attModifier.attribute(), attModifier.modifier(), attModifier.getGroup())); + .forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); return this; @@ -932,7 +967,13 @@ public ItemBuilder removeAttributeModifiers(Attribute... attributes) { itemStack.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS).modifiers().stream() .filter(attModifier -> !list.contains(attModifier.attribute())) - .forEach(attModifier -> itemAttributeModifiers.addModifier(attModifier.attribute(), attModifier.modifier(), attModifier.getGroup())); + .forEach(attModifier -> + itemAttributeModifiers.addModifier( + attModifier.attribute(), + attModifier.modifier(), + attModifier.getGroup() + ) + ); itemStack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, itemAttributeModifiers.build()); return this; From 8149efa6fc1ab03da173e8ef51e62c74f7fe76e2 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:25:53 +0100 Subject: [PATCH 14/23] reviews 2 --- src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java | 5 +---- src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java | 2 +- .../kikoapi/menu/component/container/Paginator.java | 4 +++- .../kikoapi/menu/component/interactive/Selector.java | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java index 6a517ff..99a7f18 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -65,7 +65,7 @@ protected Menu(Player player, MenuContext context) { */ public void open() { this.componentIDs.clear(); - context.getMenu(this); + context.setMenu(this); Component title = this.getTitle(); this.root = this.getRoot(this.context); @@ -150,9 +150,6 @@ public void registerComponentID(String id, MenuComponent component) { Preconditions.checkArgument(!id.isEmpty(), "id cannot be empty"); Preconditions.checkNotNull(component, "component cannot be null"); - if (this.componentIDs.containsKey(id)) - throw new IllegalStateException("A component with id '" + id + "' is already registered."); - this.componentIDs.put(id, component); } diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java index c064ed4..fcc3eb2 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/MenuContext.java @@ -91,7 +91,7 @@ public boolean hasPreviousMenu() { * @param menu the new menu to set * @throws NullPointerException if menu is null */ - void getMenu(Menu menu) { + void setMenu(Menu menu) { Preconditions.checkNotNull(menu, "menu cannot be null"); if (this.firstMenuSet) { this.firstMenuSet = false; 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 index 13f1e57..d6ca295 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -223,7 +223,9 @@ private ObjectList getCurrentPageComponents() { int maxItemsPerPage = this.width * this.height; int totalItems = this.components.size(); - int startIndex = this.page * maxItemsPerPage; + int maxPage = Math.max(0, (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1); + int safePage = Math.min(this.page, maxPage); + int startIndex = Math.min(safePage * maxItemsPerPage, totalItems); int endIndex = Math.min(startIndex + maxItemsPerPage, totalItems); this.cachedPageComponents = new ObjectArrayList<>(this.components.subList(startIndex, endIndex)); 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 index 05bf520..3aa921b 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -213,7 +213,6 @@ public Selector addOption(Function item, @Nullable T * * @param value the value of the option to remove * @return this selector for method chaining - * @throws NullPointerException if value is null */ @Contract(value = "_ -> this", mutates = "this") public Selector removeOption(@Nullable T value) { From e108a059829ef18a6c39bd2711145ef0c28b949b Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:48:56 +0100 Subject: [PATCH 15/23] reviews 3 --- .../fr/kikoplugins/kikoapi/menu/Menu.java | 4 +++- .../menu/component/container/Paginator.java | 20 +++++++++++-------- .../menu/component/interactive/Selector.java | 3 +++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java index 99a7f18..fbc3df2 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -112,7 +112,9 @@ public void close(boolean event) { if (!event) this.player.closeInventory(); - this.context.close(); + if (this.context.getMenu() == this) + this.context.close(); + this.onClose(); } 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 index d6ca295..9dcd855 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -223,9 +223,13 @@ private ObjectList getCurrentPageComponents() { int maxItemsPerPage = this.width * this.height; int totalItems = this.components.size(); + int maxPage = Math.max(0, (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1); int safePage = Math.min(this.page, maxPage); - int startIndex = Math.min(safePage * maxItemsPerPage, totalItems); + 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)); @@ -381,10 +385,12 @@ public Paginator add(MenuContext context, MenuComponent component) { 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; } @@ -433,13 +439,9 @@ public Paginator remove(MenuContext context, @NonNegative int slot) { if (!component.getSlots(context).contains(slot)) continue; - this.components.remove(component); - String removedID = component.getID(); - if (removedID != null) - context.getMenu().unregisterComponentID(removedID); - - return this; + return this.remove(context, component); } + return this; } @@ -456,10 +458,12 @@ 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; } @@ -478,7 +482,7 @@ public Paginator removeAll(MenuContext context, IntSet indexes) { int[] sorted = indexes.toIntArray(); Arrays.sort(sorted); - for (int i = sorted.length - 1; i >= 0; i--) { + for (int i = 0; i < sorted.length - 1; i++) { int index = sorted[i]; if (index >= this.components.size()) continue; // The next indexes will always be bigger 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 index 3aa921b..2cff39b 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -224,6 +224,9 @@ public Selector removeOption(@Nullable T value) { } } + if (removedIndex != -1 && this.options.size() == 1) + throw new IllegalStateException("Cannot remove the last option from the selector"); + this.options.removeIf(option -> Objects.equals(option.value, value)); if (removedIndex >= 0 && removedIndex < this.currentIndex) From 0ebbec3b3541f5bf1051624c4609a5badde2cc69 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:39:22 +0100 Subject: [PATCH 16/23] Fixed dupes & fixed losing items, added comments the code is weird to understand --- .../kikoapi/menu/listeners/MenuListener.java | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java index 2ebe9ab..355144d 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java @@ -2,33 +2,61 @@ import fr.kikoplugins.kikoapi.menu.Menu; import fr.kikoplugins.kikoapi.menu.event.KikoInventoryClickEvent; -import org.bukkit.entity.Player; 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.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 || event.getCurrentItem() == null) + if (inventory == null) return; - Player player = (Player) event.getWhoClicked(); - - InventoryHolder topHolder = player.getOpenInventory().getTopInventory().getHolder(false); - if (topHolder instanceof Menu) + // 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); + } } From ed6c243fe0c4d28da0996179201eb14533352dc3 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:01:26 +0100 Subject: [PATCH 17/23] Implement a better, more efficient, and more customizable way of detecting clicks for Button --- .../menu/component/interactive/Button.java | 144 +++++++----------- .../kikoapi/menu/test/BasicTestMenu.java | 4 + 2 files changed, 61 insertions(+), 87 deletions(-) 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 index dc9a6fb..26a445f 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -7,10 +7,13 @@ 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.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; 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; @@ -18,7 +21,9 @@ 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; @@ -36,8 +41,7 @@ @NullMarked public class Button extends MenuComponent { private Function item; - @Nullable private Consumer onClick, onDrop; - @Nullable private Consumer onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick; + private final Object2ObjectMap, Consumer> onClickMap; @Nullable private Sound sound; @Nullable private Function> animationFrames; @@ -59,12 +63,7 @@ private Button(Builder builder) { super(builder); this.item = builder.item; - this.onClick = builder.onClick; - this.onLeftClick = builder.onLeftClick; - this.onRightClick = builder.onRightClick; - this.onShiftLeftClick = builder.onShiftLeftClick; - this.onShiftRightClick = builder.onShiftRightClick; - this.onDrop = builder.onDrop; + this.onClickMap = builder.onClickMap; this.sound = builder.sound; @@ -130,29 +129,28 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { if (!this.isInteractable()) return; - Consumer handler = switch (event.getClick()) { - case LEFT, DOUBLE_CLICK -> this.onLeftClick; - case RIGHT -> this.onRightClick; - case SHIFT_LEFT -> this.onShiftLeftClick; - case SHIFT_RIGHT -> this.onShiftRightClick; - case DROP, CONTROL_DROP -> this.onDrop; - default -> null; - }; + Consumer handler = null; + for (Map.Entry, Consumer> entry : this.onClickMap.entrySet()) { + EnumSet clickTypes = entry.getKey(); + if (!clickTypes.contains(event.getClick())) + continue; - if (handler != null) { - handler.accept(event); + handler = entry.getValue(); - if (this.sound != null) - context.getPlayer().playSound(this.sound, Sound.Emitter.self()); - return; + // 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 (this.onClick != null && event.getClick().isMouseClick()) { - this.onClick.accept(event); + if (handler == null) + return; - if (this.sound != null) - context.getPlayer().playSound(this.sound, Sound.Emitter.self()); - } + handler.accept(event); + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } /** @@ -291,7 +289,7 @@ public Button item(Function item) { public Button onClick(Consumer onClick) { Preconditions.checkNotNull(onClick, "onClick cannot be null"); - this.onClick = onClick; + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); return this; } @@ -306,7 +304,7 @@ public Button onClick(Consumer onClick) { public Button onLeftClick(Consumer onLeftClick) { Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); - this.onLeftClick = onLeftClick; + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); return this; } @@ -321,37 +319,7 @@ public Button onLeftClick(Consumer onLeftClick) { public Button onRightClick(Consumer onRightClick) { Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); - this.onRightClick = onRightClick; - return this; - } - - /** - * Sets the shift+left click handler. - * - * @param onShiftLeftClick the shift+left click handler - * @return this button for method chaining - * @throws NullPointerException if onShiftLeftClick is null - */ - @Contract(value = "_ -> this", mutates = "this") - public Button onShiftLeftClick(Consumer onShiftLeftClick) { - Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); - - this.onShiftLeftClick = onShiftLeftClick; - return this; - } - - /** - * Sets the shift+right click handler. - * - * @param onShiftRightClick the shift+right click handler - * @return this button for method chaining - * @throws NullPointerException if onShiftRightClick is null - */ - @Contract(value = "_ -> this", mutates = "this") - public Button onShiftRightClick(Consumer onShiftRightClick) { - Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); - - this.onShiftRightClick = onShiftRightClick; + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); return this; } @@ -366,7 +334,7 @@ public Button onShiftRightClick(Consumer onShiftRightCl public Button onDrop(Consumer onDrop) { Preconditions.checkNotNull(onDrop, "onDrop cannot be null"); - this.onDrop = onDrop; + this.onClickMap.put(EnumSet.of(ClickType.DROP, ClickType.CONTROL_DROP), onDrop); return this; } @@ -457,8 +425,7 @@ public Button stopUpdatesOnHide(boolean stopUpdatesOnHide) { public static class Builder extends MenuComponent.Builder { private Function item = context -> ItemStack.of(Material.STONE); - @Nullable - private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDrop; + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectOpenHashMap<>(); @Nullable private Sound sound = Sound.sound( @@ -468,8 +435,7 @@ public static class Builder extends MenuComponent.Builder { 1F ); - @Nullable - private Function> animationFrames; + @Nullable private Function> animationFrames; private int animationInterval = -1; private boolean stopAnimationOnHide = true; @@ -517,7 +483,7 @@ public Builder item(Function item) { public Builder onClick(Consumer onClick) { Preconditions.checkNotNull(onClick, "onClick cannot be null"); - this.onClick = onClick; + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); return this; } @@ -532,7 +498,7 @@ public Builder onClick(Consumer onClick) { public Builder onLeftClick(Consumer onLeftClick) { Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); - this.onLeftClick = onLeftClick; + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); return this; } @@ -547,52 +513,56 @@ public Builder onLeftClick(Consumer onLeftClick) { public Builder onRightClick(Consumer onRightClick) { Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); - this.onRightClick = onRightClick; + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); return this; } /** - * Sets the shift+left click handler. + * Sets the drop action handler. * - * @param onShiftLeftClick the shift+left click handler + * @param onDrop the drop action handler * @return this builder for method chaining - * @throws NullPointerException if onShiftLeftClick is null + * @throws NullPointerException if onDrop is null */ @Contract(value = "_ -> this", mutates = "this") - public Builder onShiftLeftClick(Consumer onShiftLeftClick) { - Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); + public Builder onDrop(Consumer onDrop) { + Preconditions.checkNotNull(onDrop, "onDrop cannot be null"); - this.onShiftLeftClick = onShiftLeftClick; + this.onClickMap.put(EnumSet.of(ClickType.DROP, ClickType.CONTROL_DROP), onDrop); return this; } /** - * Sets the shift+right click handler. + * Sets a click handler for specific click types. * - * @param onShiftRightClick the shift+right click handler + * @param clickType the click type to handle + * @param onClick the click handler * @return this builder for method chaining - * @throws NullPointerException if onShiftRightClick is null + * @throws NullPointerException if clickType or onClick is null */ - @Contract(value = "_ -> this", mutates = "this") - public Builder onShiftRightClick(Consumer onShiftRightClick) { - Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be 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.onShiftRightClick = onShiftRightClick; + this.onClickMap.put(EnumSet.of(clickType), onClick); return this; } /** - * Sets the drop action handler. + * Sets a click handler for multiple click types. * - * @param onDrop the drop action handler + * @param clickTypes the click types to handle + * @param onClick the click handler * @return this builder for method chaining - * @throws NullPointerException if onDrop is null + * @throws NullPointerException if clickTypes or onClick is null */ - @Contract(value = "_ -> this", mutates = "this") - public Builder onDrop(Consumer onDrop) { - Preconditions.checkNotNull(onDrop, "onDrop cannot be 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.onDrop = onDrop; + this.onClickMap.put(clickTypes, onClick); return this; } diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java index 1eff6da..e624f8c 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/test/BasicTestMenu.java @@ -18,6 +18,7 @@ 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; @@ -55,6 +56,9 @@ private static Button simpleButton() { click.getPlayer().sendRichMessage("Newton"); click.getPlayer().closeInventory(); }) + .onClick(ClickType.SWAP_OFFHAND, click -> { + click.getPlayer().sendRichMessage("Secret Hehe :3"); + }) .build(); } From 64bee414430e0d3331c9330ebd89eb92267252a9 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:21:34 +0100 Subject: [PATCH 18/23] Add tests for new classes --- .gitignore | 5 +- .../kikoapi/utils/EnumUtilsTest.java | 23 + .../kikoapi/utils/ItemBuilderTest.java | 1113 +++++++++++++++++ .../kikoapi/utils/StringUtilsTest.java | 6 +- 4 files changed, 1144 insertions(+), 3 deletions(-) create mode 100644 src/test/java/fr/kikoplugins/kikoapi/utils/EnumUtilsTest.java create mode 100644 src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java diff --git a/.gitignore b/.gitignore index f51a8a8..0f5a310 100644 --- a/.gitignore +++ b/.gitignore @@ -119,4 +119,7 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/intellij+all,gradle # run-paper -run/** \ No newline at end of file +run/** + +# MockBukkit +logs/* \ No newline at end of file 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..e615447 --- /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 builer = ItemBuilder.of(Material.APPLE, 7); + assertEquals(7, builer.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")); } - } From 868e019161da706c6fbacda9de519309097d60b2 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:12:52 +0100 Subject: [PATCH 19/23] Add closing detection of menus --- .../fr/kikoplugins/kikoapi/menu/Menu.java | 27 +++++++++++++------ .../menu/component/container/Paginator.java | 2 +- .../kikoapi/menu/listeners/MenuListener.java | 15 +++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java index fbc3df2..049f5d2 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/Menu.java @@ -8,6 +8,7 @@ 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; @@ -99,23 +100,31 @@ public void reopen() { } /** - * Closes the menu and performs cleanup. + * 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 event whether this close was triggered by an inventory close event. - * If false, the player's inventory will be closed programmatically. + * @param reason the reason for closing the inventory */ - public void close(boolean event) { + 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 (!event) + if (reason == InventoryCloseEvent.Reason.PLUGIN) this.player.closeInventory(); if (this.context.getMenu() == this) this.context.close(); - this.onClose(); + this.onClose(reason); } /** @@ -213,9 +222,11 @@ protected void onOpen() { * Called when the menu is closed. *

* Subclasses can override this method to perform actions when the menu - * is closed. + * is closed, such as cleanup or saving state. + * + * @param reason the reason for closing the inventory */ - protected void onClose() { + protected void onClose(InventoryCloseEvent.Reason reason) { } 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 index 9dcd855..d1a0a04 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -224,7 +224,7 @@ private ObjectList getCurrentPageComponents() { int maxItemsPerPage = this.width * this.height; int totalItems = this.components.size(); - int maxPage = Math.max(0, (int) Math.ceil((double) totalItems / maxItemsPerPage) - 1); + 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; diff --git a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java index 355144d..63c8f96 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/listeners/MenuListener.java @@ -6,6 +6,7 @@ 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; @@ -59,4 +60,18 @@ public void onInventoryDrag(InventoryDragEvent event) { 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); + } } From 97145a7ee01e70a3b895f344a924675f68effe8d Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:22:33 +0100 Subject: [PATCH 20/23] Add the new clicking thingy on the DoubleDropButton --- .../menu/component/interactive/Button.java | 2 +- .../interactive/DoubleDropButton.java | 155 ++++++++---------- 2 files changed, 66 insertions(+), 91 deletions(-) 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 index 26a445f..798d9c6 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -63,7 +63,7 @@ private Button(Builder builder) { super(builder); this.item = builder.item; - this.onClickMap = builder.onClickMap; + this.onClickMap = new Object2ObjectOpenHashMap<>(builder.onClickMap); this.sound = builder.sound; 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 index 2cf49f8..57f6f39 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -7,6 +7,8 @@ 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.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import org.bukkit.Material; @@ -17,6 +19,8 @@ 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; @@ -33,12 +37,13 @@ public class DoubleDropButton extends MenuComponent { private Function item; private Function dropItem; - @Nullable - private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDoubleDrop; - @Nullable - private Sound sound; - @Nullable - private BukkitTask dropTask; + + 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. @@ -50,11 +55,7 @@ private DoubleDropButton(Builder builder) { this.item = builder.item; this.dropItem = builder.dropItem; - this.onClick = builder.onClick; - this.onLeftClick = builder.onLeftClick; - this.onRightClick = builder.onRightClick; - this.onShiftLeftClick = builder.onShiftLeftClick; - this.onShiftRightClick = builder.onShiftRightClick; + this.onClickMap = new Object2ObjectOpenHashMap<>(builder.onClickMap); this.onDoubleDrop = builder.onDoubleDrop; this.sound = builder.sound; @@ -63,7 +64,7 @@ private DoubleDropButton(Builder builder) { /** * Creates a new DoubleDropButton builder instance. * - * @return a new DoubleDropButton.Builder for constructing buttons + * @return a new DoubleDropBuilder for constructing buttons */ @Contract(value = "-> new", pure = true) public static Builder create() { @@ -71,7 +72,7 @@ public static Builder create() { } /** - * Called when this button is removed from a menu. + * Called when this double drop button is removed from a menu. *

* Cancels any pending drop task to prevent memory leaks and * ensure proper cleanup. @@ -85,12 +86,12 @@ public void onRemove(MenuContext context) { } /** - * Handles click events on this button. + * Handles click events on this double drop button. *

- * The button supports several interaction modes: - * - Drop clicks: First drop enters "drop state", second drop within 3 seconds triggers double-drop - * - Specific click handlers: Left, right, shift+left, shift+right clicks - * - General click handler: Fallback for other mouse clicks + * 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 @@ -106,28 +107,28 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { return; } - Consumer handler = switch (event.getClick()) { - case LEFT, DOUBLE_CLICK -> this.onLeftClick; - case RIGHT -> this.onRightClick; - case SHIFT_LEFT -> this.onShiftLeftClick; - case SHIFT_RIGHT -> this.onShiftRightClick; - default -> null; - }; + Consumer handler = null; + for (Map.Entry, Consumer> entry : this.onClickMap.entrySet()) { + EnumSet clickTypes = entry.getKey(); + if (!clickTypes.contains(event.getClick())) + continue; - if (handler != null) { - handler.accept(event); + handler = entry.getValue(); - if (this.sound != null) - context.getPlayer().playSound(this.sound, Sound.Emitter.self()); - return; + // 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 (this.onClick != null && click.isMouseClick()) { - this.onClick.accept(event); + if (handler == null) + return; - if (this.sound != null) - context.getPlayer().playSound(this.sound, Sound.Emitter.self()); - } + handler.accept(event); + + if (this.sound != null) + context.getPlayer().playSound(this.sound, Sound.Emitter.self()); } /** @@ -158,9 +159,9 @@ private void handleDropClick(KikoInventoryClickEvent event, MenuContext context) } /** - * Returns the items to be displayed by this button. + * Returns the items to be displayed by this double drop button. *

- * The button fills all slots within its widthxheight area with the + * 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 @@ -242,7 +243,7 @@ public DoubleDropButton dropItem(Function dropItem) { public DoubleDropButton onClick(Consumer onClick) { Preconditions.checkNotNull(onClick, "onClick cannot be null"); - this.onClick = onClick; + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); return this; } @@ -257,7 +258,7 @@ public DoubleDropButton onClick(Consumer onClick) { public DoubleDropButton onLeftClick(Consumer onLeftClick) { Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); - this.onLeftClick = onLeftClick; + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); return this; } @@ -272,37 +273,7 @@ public DoubleDropButton onLeftClick(Consumer onLeftClic public DoubleDropButton onRightClick(Consumer onRightClick) { Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); - this.onRightClick = onRightClick; - return this; - } - - /** - * Sets the shift+left click handler. - * - * @param onShiftLeftClick the shift+left click handler - * @return this double drop button for method chaining - * @throws NullPointerException if onShiftLeftClick is null - */ - @Contract(value = "_ -> this", mutates = "this") - public DoubleDropButton onShiftLeftClick(Consumer onShiftLeftClick) { - Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be null"); - - this.onShiftLeftClick = onShiftLeftClick; - return this; - } - - /** - * Sets the shift+right click handler. - * - * @param onShiftRightClick the shift+right click handler - * @return this double drop button for method chaining - * @throws NullPointerException if onShiftRightClick is null - */ - @Contract(value = "_ -> this", mutates = "this") - public DoubleDropButton onShiftRightClick(Consumer onShiftRightClick) { - Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be null"); - - this.onShiftRightClick = onShiftRightClick; + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); return this; } @@ -350,8 +321,8 @@ public static class Builder extends MenuComponent.Builder { private Function item = context -> ItemStack.of(Material.STONE); private Function dropItem = context -> ItemStack.of(Material.DIRT); - @Nullable - private Consumer onClick, onLeftClick, onRightClick, onShiftLeftClick, onShiftRightClick, onDoubleDrop; + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectOpenHashMap<>(); + @Nullable private Consumer onDoubleDrop; @Nullable private Sound sound = Sound.sound( @@ -432,7 +403,7 @@ public Builder dropItem(Function dropItem) { public Builder onClick(Consumer onClick) { Preconditions.checkNotNull(onClick, "onClick cannot be null"); - this.onClick = onClick; + this.onClickMap.put(EnumSet.allOf(ClickType.class), onClick); return this; } @@ -447,7 +418,7 @@ public Builder onClick(Consumer onClick) { public Builder onLeftClick(Consumer onLeftClick) { Preconditions.checkNotNull(onLeftClick, "onLeftClick cannot be null"); - this.onLeftClick = onLeftClick; + this.onClickMap.put(EnumSet.of(ClickType.LEFT), onLeftClick); return this; } @@ -462,37 +433,41 @@ public Builder onLeftClick(Consumer onLeftClick) { public Builder onRightClick(Consumer onRightClick) { Preconditions.checkNotNull(onRightClick, "onRightClick cannot be null"); - this.onRightClick = onRightClick; + this.onClickMap.put(EnumSet.of(ClickType.RIGHT), onRightClick); return this; } /** - * Sets the shift+left click handler. + * Sets a click handler for specific click types. * - * @param onShiftLeftClick the shift+left click handler + * @param clickType the click type to handle + * @param onClick the click handler * @return this builder for method chaining - * @throws NullPointerException if onShiftLeftClick is null + * @throws NullPointerException if clickType or onClick is null */ - @Contract(value = "_ -> this", mutates = "this") - public Builder onShiftLeftClick(Consumer onShiftLeftClick) { - Preconditions.checkNotNull(onShiftLeftClick, "onShiftLeftClick cannot be 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.onShiftLeftClick = onShiftLeftClick; + this.onClickMap.put(EnumSet.of(clickType), onClick); return this; } /** - * Sets the shift+right click handler. + * Sets a click handler for multiple click types. * - * @param onShiftRightClick the shift+right click handler + * @param clickTypes the click types to handle + * @param onClick the click handler * @return this builder for method chaining - * @throws NullPointerException if onShiftRightClick is null + * @throws NullPointerException if clickTypes or onClick is null */ - @Contract(value = "_ -> this", mutates = "this") - public Builder onShiftRightClick(Consumer onShiftRightClick) { - Preconditions.checkNotNull(onShiftRightClick, "onShiftRightClick cannot be 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.onShiftRightClick = onShiftRightClick; + this.onClickMap.put(clickTypes, onClick); return this; } @@ -512,7 +487,7 @@ public Builder onDoubleDrop(Consumer onDoubleDrop) { } /** - * Sets the sound to play when the button is clicked. + * 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 From 87212fcb410f0b092a6b51f62066a3a71a49d4fd Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:43:54 +0100 Subject: [PATCH 21/23] review --- .../kikoapi/menu/component/container/Paginator.java | 3 +-- .../kikoapi/menu/component/interactive/Button.java | 5 +++++ .../kikoapi/menu/component/interactive/Selector.java | 9 ++++++--- .../kikoapi/menu/component/interactive/Toggle.java | 5 ++--- .../kikoplugins/kikoapi/menu/component/layout/Grid.java | 2 +- .../kikoapi/menu/component/premade/ConfirmationMenu.java | 1 - .../java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java | 9 +++++++-- .../fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java | 4 ++-- 8 files changed, 24 insertions(+), 14 deletions(-) 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 index d1a0a04..e8ecb2f 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/container/Paginator.java @@ -482,8 +482,7 @@ public Paginator removeAll(MenuContext context, IntSet indexes) { int[] sorted = indexes.toIntArray(); Arrays.sort(sorted); - for (int i = 0; i < sorted.length - 1; i++) { - int index = sorted[i]; + for (int index : sorted) { if (index >= this.components.size()) continue; // The next indexes will always be bigger 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 index 798d9c6..4ae2f9a 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -179,6 +179,11 @@ private void startAnimation(MenuContext context) { return; } + if (this.animationFrames == null) { + stopAnimation(); + return; + } + List frames = this.animationFrames.apply(context); if (frames.isEmpty()) return; 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 index 2cff39b..edb82ff 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Selector.java @@ -224,12 +224,15 @@ public Selector removeOption(@Nullable T value) { } } - if (removedIndex != -1 && this.options.size() == 1) + if (removedIndex == -1) + return this; + + if (this.options.size() == 1) throw new IllegalStateException("Cannot remove the last option from the selector"); - this.options.removeIf(option -> Objects.equals(option.value, value)); + this.options.remove(removedIndex); - if (removedIndex >= 0 && removedIndex < this.currentIndex) + if (removedIndex < this.currentIndex) this.currentIndex--; else if (this.currentIndex >= this.options.size()) this.currentIndex = Math.max(0, this.options.size() - 1); 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 index ed9c285..fe0f53b 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -78,7 +78,6 @@ public void onClick(KikoInventoryClickEvent event, MenuContext context) { context.getPlayer().playSound(this.sound, Sound.Emitter.self()); this.currentState = !this.currentState; - this.render(context); if (this.onToggle != null) { ToggleEvent toggleEvent = new ToggleEvent(event, this.currentState); @@ -215,8 +214,8 @@ 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.STONE); - private Function offItem = context -> ItemStack.of(Material.STONE); + private Function onItem = context -> ItemStack.of(Material.LIME_DYE); + private Function offItem = context -> ItemStack.of(Material.RED_DYE); @Nullable private Consumer onToggle; 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 index 915b708..72f15ad 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/layout/Grid.java @@ -55,7 +55,7 @@ public static Builder create() { /** * Called when this grid is added to a menu. *

- * Propagates the onAdd event to all child components if the grid is visible. + * Propagates the onAdd event to all child components. * * @param context the menu context */ 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 index 28ea2a9..77e4f5c 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java @@ -53,7 +53,6 @@ public ConfirmationMenu( ) { super(player); - Preconditions.checkNotNull(player, "player cannot be null"); Preconditions.checkNotNull(title, "title cannot be null"); Preconditions.checkNotNull(yesItem, "yesMaterial cannot be null"); Preconditions.checkNotNull(noItem, "noMaterial cannot be null"); diff --git a/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java index e282bbe..fbdd1ef 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java +++ b/src/main/java/fr/kikoplugins/kikoapi/utils/ItemBuilder.java @@ -766,7 +766,11 @@ public ItemBuilder setLoreLine(Component line, @NonNegative int index) { public ItemBuilder removeLoreLine(Component line) { Preconditions.checkNotNull(line, "line cannot be null"); - List lore = new ArrayList<>(itemStack.getData(DataComponentTypes.LORE).lines()); + 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)); @@ -1011,7 +1015,8 @@ public Map attributeModifiers() { : data.modifiers().stream() .collect(Collectors.toMap( ItemAttributeModifiers.Entry::attribute, - ItemAttributeModifiers.Entry::modifier + ItemAttributeModifiers.Entry::modifier, + (existing, replacement) -> replacement )); } diff --git a/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java b/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java index e615447..e39ee28 100644 --- a/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java +++ b/src/test/java/fr/kikoplugins/kikoapi/utils/ItemBuilderTest.java @@ -158,8 +158,8 @@ void amountSetter_shouldThrowWhenAmountLessThanOne(int amount) { @Test @DisplayName("amount() should return current stack size of the item") void amountGetter_shouldReturnCurrentStackSizeWhenCalled() { - ItemBuilder builer = ItemBuilder.of(Material.APPLE, 7); - assertEquals(7, builer.amount()); + ItemBuilder builder = ItemBuilder.of(Material.APPLE, 7); + assertEquals(7, builder.amount()); } @Test From 42317f7c5a29b9896a668904272c312a5e536375 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:16:30 +0100 Subject: [PATCH 22/23] review 2 --- .../kikoapi/menu/component/interactive/Button.java | 2 +- .../kikoapi/menu/component/interactive/Toggle.java | 14 +++++++------- .../menu/component/premade/ConfirmationMenu.java | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) 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 index 4ae2f9a..fe40c02 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -236,7 +236,7 @@ private void stopUpdates() { /** * Gets the ItemStack to display based on the current button configuration. *

- * Priority order: dynamic item → animation frame → static item + * Priority order: animation frame (if set and non-empty) → item function * * @param context the menu context * @return the appropriate ItemStack for the current state 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 index fe0f53b..47fc75b 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Toggle.java @@ -113,7 +113,7 @@ private ItemStack getCurrentItem(MenuContext context) { * Sets the ItemStack to display when the toggle is in the "on" state. * * @param onItem the ItemStack for the "on" state - * @return this selector for method chaining + * @return this toggle for method chaining * @throws NullPointerException if onItem is null */ @Contract(value = "_ -> this", mutates = "this") @@ -128,7 +128,7 @@ public Toggle onItem(ItemStack onItem) { * Sets the ItemStack to display when the toggle is in the "off" state. * * @param offItem the ItemStack for the "off" state - * @return this selector for method chaining + * @return this toggle for method chaining * @throws NullPointerException if offItem is null */ @Contract(value = "_ -> this", mutates = "this") @@ -143,7 +143,7 @@ public Toggle offItem(ItemStack offItem) { * Sets a function to provide the ItemStack for the "on" state. * * @param onItem function that returns the ItemStack for the "on" state - * @return this selector for method chaining + * @return this toggle for method chaining * @throws NullPointerException if onItem is null */ @Contract(value = "_ -> this", mutates = "this") @@ -158,7 +158,7 @@ public Toggle onItem(Function onItem) { * Sets a function to provide the ItemStack for the "off" state. * * @param offItem function that returns the ItemStack for the "off" state - * @return this selector for method chaining + * @return this toggle for method chaining * @throws NullPointerException if offItem is null */ @Contract(value = "_ -> this", mutates = "this") @@ -173,7 +173,7 @@ public Toggle offItem(Function offItem) { * Sets the toggle state change handler. * * @param onToggle the consumer to handle toggle state changes - * @return this selector for method chaining + * @return this toggle for method chaining * @throws NullPointerException if onToggle is null */ @Contract(value = "_ -> this", mutates = "this") @@ -188,7 +188,7 @@ public Toggle onToggle(Consumer onToggle) { * Sets the sound to play when the toggle is clicked. * * @param sound the sound to play, or null for no sound - * @return this selector for method chaining + * @return this toggle for method chaining */ @Contract(value = "_ -> this", mutates = "this") public Toggle sound(@Nullable Sound sound) { @@ -200,7 +200,7 @@ public Toggle sound(@Nullable Sound sound) { * Sets the initial state of the toggle. * * @param state true for "on" state, false for "off" state - * @return this selector for method chaining + * @return this toggle for method chaining */ @Contract(value = "_ -> this", mutates = "this") public Toggle currentState(boolean state) { 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 index 77e4f5c..8aa532c 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/premade/ConfirmationMenu.java @@ -36,9 +36,9 @@ public class ConfirmationMenu extends Menu { * 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 title the title component displayed at the top of the menu * @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 @@ -54,8 +54,8 @@ public ConfirmationMenu( super(player); Preconditions.checkNotNull(title, "title cannot be null"); - Preconditions.checkNotNull(yesItem, "yesMaterial cannot be null"); - Preconditions.checkNotNull(noItem, "noMaterial 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"); From 950061195d5697905281134a21db4930161df0f3 Mon Sep 17 00:00:00 2001 From: PuppyTransGirl <74014559+PuppyTransGirl@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:21:22 +0100 Subject: [PATCH 23/23] Object2ObjectLinkedOpenHashMap --- .../kikoapi/menu/component/interactive/Button.java | 6 +++--- .../menu/component/interactive/DoubleDropButton.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index fe40c02..7516cf8 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/Button.java @@ -7,8 +7,8 @@ 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.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectList; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -63,7 +63,7 @@ private Button(Builder builder) { super(builder); this.item = builder.item; - this.onClickMap = new Object2ObjectOpenHashMap<>(builder.onClickMap); + this.onClickMap = new Object2ObjectLinkedOpenHashMap<>(builder.onClickMap); this.sound = builder.sound; @@ -430,7 +430,7 @@ public Button stopUpdatesOnHide(boolean stopUpdatesOnHide) { public static class Builder extends MenuComponent.Builder { private Function item = context -> ItemStack.of(Material.STONE); - private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectOpenHashMap<>(); + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectLinkedOpenHashMap<>(); @Nullable private Sound sound = Sound.sound( 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 index 57f6f39..38d6e18 100644 --- a/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java +++ b/src/main/java/fr/kikoplugins/kikoapi/menu/component/interactive/DoubleDropButton.java @@ -7,8 +7,8 @@ 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.Object2ObjectOpenHashMap; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import org.bukkit.Material; @@ -55,7 +55,7 @@ private DoubleDropButton(Builder builder) { this.item = builder.item; this.dropItem = builder.dropItem; - this.onClickMap = new Object2ObjectOpenHashMap<>(builder.onClickMap); + this.onClickMap = new Object2ObjectLinkedOpenHashMap<>(builder.onClickMap); this.onDoubleDrop = builder.onDoubleDrop; this.sound = builder.sound; @@ -321,7 +321,7 @@ 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 Object2ObjectOpenHashMap<>(); + private final Object2ObjectMap, Consumer> onClickMap = new Object2ObjectLinkedOpenHashMap<>(); @Nullable private Consumer onDoubleDrop; @Nullable