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