package com.github.stefvanschie.inventoryframework; import com.github.stefvanschie.inventoryframework.exception.XMLLoadException; import com.github.stefvanschie.inventoryframework.pane.*; import com.github.stefvanschie.inventoryframework.pane.component.*; import com.github.stefvanschie.inventoryframework.util.XMLUtil; import org.apache.commons.lang3.reflect.MethodUtils; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.entity.HumanEntity; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import javax.xml.parsers.DocumentBuilderFactory; import java.io.InputStream; import java.util.*; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; /** * The base class of all GUIs */ public class Gui implements InventoryHolder { /** * A set of all panes in this inventory */ @NotNull private final List<Pane> panes; /** * The inventory of this gui */ @NotNull private Inventory inventory; /** * The title of this gui */ @NotNull private String title; /** * The state of this gui */ @NotNull private State state = State.TOP; /** * A player cache for storing player's inventories */ @NotNull private final HumanEntityCache humanEntityCache = new HumanEntityCache(); /** * The consumer that will be called once a players clicks in the top-half of the gui */ @Nullable private Consumer<InventoryClickEvent> onTopClick; /** * The consumer that will be called once a players clicks in the bottom-half of the gui */ @Nullable private Consumer<InventoryClickEvent> onBottomClick; /** * The consumer that will be called once a players clicks in the gui or in their inventory */ @Nullable private Consumer<InventoryClickEvent> onGlobalClick; /** * The consumer that will be called once a player clicks outside of the gui screen */ @Nullable private Consumer<InventoryClickEvent> onOutsideClick; /** * The consumer that will be called once a player closes the gui */ @Nullable private Consumer<InventoryCloseEvent> onClose; /** * Whether this gui is updating (as invoked by {@link #update()}), true if this is the case, false otherwise. This * is used to indicate that inventory close events due to updating should be ignored. */ private boolean updating = false; /** * The pane mapping which will allow users to register their own panes to be used in XML files */ @NotNull private static final Map<String, BiFunction<Object, Element, Pane>> PANE_MAPPINGS = new HashMap<>(); /** * Whether listeners have ben registered by some gui */ private static boolean hasRegisteredListeners; /** * Constructs a new GUI * * @param plugin the main plugin. * @param rows the amount of rows this gui should contain, in range 1..6. * @param title the title/name of this gui. * @deprecated use {@link #Gui(int, String)} instead */ @Deprecated public Gui(@NotNull Plugin plugin, int rows, @NotNull String title) { this(rows, title); } /** * Constructs a new GUI * * @param rows the amount of rows this gui should contain, in range 1..6. * @param title the title/name of this gui. * @since 0.6.0 */ public Gui(int rows, @NotNull String title) { if (!(rows >= 1 && rows <= 6)) { throw new IllegalArgumentException("Rows should be between 1 and 6"); } this.panes = new ArrayList<>(); this.inventory = Bukkit.createInventory(this, rows * 9, title); this.title = title; if (!hasRegisteredListeners) { Bukkit.getPluginManager().registerEvents(new GuiListener(), JavaPlugin.getProvidingPlugin(getClass())); hasRegisteredListeners = true; } } /** * Adds a pane to this gui * * @param pane the pane to add */ public void addPane(@NotNull Pane pane) { this.panes.add(pane); this.panes.sort(Comparator.comparing(Pane::getPriority)); } /** * Shows a gui to a player * * @param humanEntity the human entity to show the gui to */ public void show(@NotNull HumanEntity humanEntity) { inventory.clear(); //set the state to the top, so in case there are no longer any bottom part panes, their inventory will be shown again setState(State.TOP); humanEntityCache.storeAndClear(humanEntity); //initialize the inventory first panes.stream().filter(Pane::isVisible).forEach(pane -> pane.display(this, inventory, humanEntity.getInventory(), 0, 0, 9, getRows() + 4)); //ensure that the inventory is cached before being overwritten and restore it if we end up not needing the bottom part after all if (state == State.TOP) { humanEntityCache.restoreAndForget(humanEntity); } humanEntity.openInventory(inventory); } /** * Sets the amount of rows for this inventory. * This will (unlike most other methods) directly update itself in order to ensure all viewers will still be viewing the new inventory as well. * * @param rows the amount of rows in range 1..6. */ public void setRows(int rows) { if (!(rows >= 1 && rows <= 6)) { throw new IllegalArgumentException("Rows should be between 1 and 6"); } //copy the viewers List<HumanEntity> viewers = getViewers(); this.inventory = Bukkit.createInventory(this, rows * 9, getTitle()); viewers.forEach(humanEntity -> humanEntity.openInventory(inventory)); } /** * Gets the count of {@link HumanEntity} instances that are currently viewing this GUI. * * @return the count of viewers * @since 0.5.19 */ @Contract(pure = true) public int getViewerCount() { return inventory.getViewers().size(); } /** * Gets a mutable snapshot of the current {@link HumanEntity} viewers of this GUI. * This is a snapshot (copy) and not a view, therefore modifications aren't visible. * * @return a snapshot of the current viewers * @see #getViewerCount() * @since 0.5.19 */ @NotNull @Contract(pure = true) public List<HumanEntity> getViewers() { return new ArrayList<>(inventory.getViewers()); } /** * Gets all the panes in this gui, this includes child panes from other panes * * @return all panes */ @NotNull @Contract(pure = true) public List<Pane> getPanes() { List<Pane> panes = new ArrayList<>(); this.panes.forEach(pane -> panes.addAll(pane.getPanes())); panes.addAll(this.panes); return panes; } /** * Sets the title for this inventory. This will (unlike most other methods) directly update itself in order * to ensure all viewers will still be viewing the new inventory as well. * * @param title the title */ public void setTitle(@NotNull String title) { //copy the viewers List<HumanEntity> viewers = getViewers(); this.inventory = Bukkit.createInventory(this, this.inventory.getSize(), title); this.title = title; viewers.forEach(humanEntity -> humanEntity.openInventory(inventory)); } /** * Gets all the items in all underlying panes * * @return all items */ @NotNull @Contract(pure = true) public Collection<GuiItem> getItems() { return getPanes().stream().flatMap(pane -> pane.getItems().stream()).collect(Collectors.toSet()); } /** * Update the gui for everyone */ public void update() { updating = true; getViewers().forEach(this::show); if (!updating) throw new AssertionError("Gui#isUpdating became false before Gui#update finished"); updating = false; } /** * Calling this method will set the state of this gui. If this state is set to top state, it will restore all the * stored inventories of the players and will assume no pane extends into the bottom inventory part. If the state is * set to bottom state it will assume one or more panes overflow into the bottom half of the inventory and will * store all players' inventories and clear those. * * Do not call this method if you just want the player's inventory to be cleared. * * @param state the new gui state * @since 0.4.0 */ public void setState(@NotNull State state) { this.state = state; if (state == State.TOP) { humanEntityCache.restoreAndForgetAll(); } else if (state == State.BOTTOM) { inventory.getViewers().forEach(humanEntityCache::storeAndClear); } } /** * Gets the state of this gui * * @return the state * @since 0.5.4 */ @NotNull @Contract(pure = true) public State getState() { return state; } /** * Gets the human entity cache used for this gui * * @return the human entity cache * @see HumanEntityCache * @since 0.5.4 */ @NotNull @Contract(pure = true) protected HumanEntityCache getHumanEntityCache() { return humanEntityCache; } /** * Loads a Gui from a given input stream. * Returns null instead of throwing an exception in case of a failure. * * @param plugin the main plugin * @param instance the class instance for all reflection lookups * @param inputStream the file * @return the gui or null if the loading failed * @see #loadOrThrow(Object, InputStream) * @deprecated use {@link #load(Object, InputStream)} instead */ @Nullable @Deprecated public static Gui load(@NotNull Plugin plugin, @NotNull Object instance, @NotNull InputStream inputStream) { return load(instance, inputStream); } /** * Loads a Gui from a given input stream. * Returns null instead of throwing an exception in case of a failure. * * @param instance the class instance for all reflection lookups * @param inputStream the file * @return the gui or null if the loading failed * @see #loadOrThrow(Object, InputStream) */ @Nullable public static Gui load(@NotNull Object instance, @NotNull InputStream inputStream) { try { return loadOrThrow(instance, inputStream); } catch (RuntimeException e) { e.printStackTrace(); return null; } } /** * Loads a Gui from a given input stream. * Throws a {@link RuntimeException} instead of returning null in case of a failure. * * @param plugin the main plugin * @param instance the class instance for all reflection lookups * @param inputStream the file * @return the gui * @see #load(Object, InputStream) * @deprecated use {@link #loadOrThrow(Object, InputStream)} instead */ @NotNull @Deprecated public static Gui loadOrThrow(@NotNull Plugin plugin, @NotNull Object instance, @NotNull InputStream inputStream) { return loadOrThrow(instance, inputStream); } /** * Loads a Gui from a given input stream. * Throws a {@link RuntimeException} instead of returning null in case of a failure. * * @param instance the class instance for all reflection lookups * @param inputStream the file * @return the gui * @see #load(Object, InputStream) */ @NotNull public static Gui loadOrThrow(@NotNull Object instance, @NotNull InputStream inputStream) { Plugin plugin = JavaPlugin.getProvidingPlugin(Gui.class); try { Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputStream); Element documentElement = document.getDocumentElement(); documentElement.normalize(); Gui gui = new Gui(plugin, Integer.parseInt(documentElement.getAttribute("rows")), ChatColor .translateAlternateColorCodes('&', documentElement.getAttribute("title"))); if (documentElement.hasAttribute("field")) XMLUtil.loadFieldAttribute(instance, documentElement, gui); if (documentElement.hasAttribute("onTopClick")) { gui.setOnTopClick(XMLUtil.loadOnEventAttribute(instance, documentElement, InventoryClickEvent.class, "onTopClick")); } if (documentElement.hasAttribute("onBottomClick")) { gui.setOnBottomClick(XMLUtil.loadOnEventAttribute(instance, documentElement, InventoryClickEvent.class, "onBottomClick")); } if (documentElement.hasAttribute("onGlobalClick")) { gui.setOnGlobalClick(XMLUtil.loadOnEventAttribute(instance, documentElement, InventoryClickEvent.class, "onGlobalClick")); } if (documentElement.hasAttribute("onOutsideClick")) { gui.setOnOutsideClick(XMLUtil.loadOnEventAttribute(instance, documentElement, InventoryClickEvent.class, "onOutsideClick")); } if (documentElement.hasAttribute("onClose")) { gui.setOnClose(XMLUtil.loadOnEventAttribute(instance, documentElement, InventoryCloseEvent.class, "onClose")); } if (documentElement.hasAttribute("populate")) { MethodUtils.invokeExactMethod(instance, "populate", gui, Gui.class); } else { NodeList childNodes = documentElement.getChildNodes(); for (int i = 0; i < childNodes.getLength(); i++) { Node item = childNodes.item(i); if (item.getNodeType() == Node.ELEMENT_NODE) gui.addPane(loadPane(instance, item)); } } return gui; } catch (Exception e) { throw new XMLLoadException("Error loading " + plugin.getName() + "'s gui with associated class: " + instance.getClass().getSimpleName(), e); } } /** * Set the consumer that should be called whenever this gui is clicked in. * * @param onTopClick the consumer that gets called */ public void setOnTopClick(@Nullable Consumer<InventoryClickEvent> onTopClick) { this.onTopClick = onTopClick; } /** * Calls the consumer (if it's not null) that was specified using {@link #setOnTopClick(Consumer)}, * so the consumer that should be called whenever this gui is clicked in. * Catches and logs all exceptions the consumer might throw. * * @param event the event to handle * @since 0.6.0 */ public void callOnTopClick(@NotNull InventoryClickEvent event) { callCallback(onTopClick, event, "onTopClick"); } /** * Set the consumer that should be called whenever the inventory is clicked in. * * @param onBottomClick the consumer that gets called */ public void setOnBottomClick(@Nullable Consumer<InventoryClickEvent> onBottomClick) { this.onBottomClick = onBottomClick; } /** * Calls the consumer (if it's not null) that was specified using {@link #setOnBottomClick(Consumer)}, * so the consumer that should be called whenever the inventory is clicked in. * Catches and logs all exceptions the consumer might throw. * * @param event the event to handle * @since 0.6.0 */ public void callOnBottomClick(@NotNull InventoryClickEvent event) { callCallback(onBottomClick, event, "onBottomClick"); } /** * Set the consumer that should be called whenever this gui or inventory is clicked in. * * @param onGlobalClick the consumer that gets called */ public void setOnGlobalClick(@Nullable Consumer<InventoryClickEvent> onGlobalClick) { this.onGlobalClick = onGlobalClick; } /** * Calls the consumer (if it's not null) that was specified using {@link #setOnGlobalClick(Consumer)}, * so the consumer that should be called whenever this gui or inventory is clicked in. * Catches and logs all exceptions the consumer might throw. * * @param event the event to handle * @since 0.6.0 */ public void callOnGlobalClick(@NotNull InventoryClickEvent event) { callCallback(onGlobalClick, event, "onGlobalClick"); } /** * Set the consumer that should be called whenever a player clicks outside the gui. * * @param onOutsideClick the consumer that gets called * @since 0.5.7 */ public void setOnOutsideClick(@Nullable Consumer<InventoryClickEvent> onOutsideClick) { this.onOutsideClick = onOutsideClick; } /** * Calls the consumer (if it's not null) that was specified using {@link #setOnOutsideClick(Consumer)}, * so the consumer that should be called whenever a player clicks outside the gui. * Catches and logs all exceptions the consumer might throw. * * @param event the event to handle * @since 0.6.0 */ public void callOnOutsideClick(@NotNull InventoryClickEvent event) { callCallback(onOutsideClick, event, "onOutsideClick"); } /** * Set the consumer that should be called whenever this gui is closed. * * @param onClose the consumer that gets called */ public void setOnClose(@Nullable Consumer<InventoryCloseEvent> onClose) { this.onClose = onClose; } /** * Calls the consumer (if it's not null) that was specified using {@link #setOnClose(Consumer)}, * so the consumer that should be called whenever this gui is closed. * Catches and logs all exceptions the consumer might throw. * * @param event the event to handle * @since 0.6.0 */ public void callOnClose(@NotNull InventoryCloseEvent event) { callCallback(onClose, event, "onClose"); } /** * Calls the specified consumer (if it's not null) with the specified parameter, * catching and logging all exceptions it might throw. * * @param callback the consumer to call if it isn't null * @param event the value the consumer should accept * @param callbackName the name of the action, used for logging * @param <T> the type of the value the consumer is accepting */ private <T extends InventoryEvent> void callCallback(@Nullable Consumer<T> callback, @NotNull T event, @NotNull String callbackName) { if (callback == null) { return; } try { callback.accept(event); } catch (Throwable t) { Logger logger = JavaPlugin.getProvidingPlugin(getClass()).getLogger(); String message = "Exception while handling " + callbackName + " in inventory '" + title + "', state=" + state; if (event instanceof InventoryClickEvent) { InventoryClickEvent clickEvent = (InventoryClickEvent) event; message += ", slot=" + clickEvent.getSlot(); } logger.log(Level.SEVERE, message, t); } } /** * Returns the amount of rows this gui currently has * * @return the amount of rows */ public int getRows() { return inventory.getSize() / 9; } /** * Returns the title of this gui * * @return the title */ @NotNull @Contract(pure = true) public String getTitle() { return title; } @NotNull @Override public Inventory getInventory() { return inventory; } /** * Gets whether this gui is being updated, as invoked by {@link #update()}. This returns true if this is the case * and false otherwise. * * @return whether this gui is being updated * @since 0.5.15 */ @Contract(pure = true) protected boolean isUpdating() { return updating; } /** * Registers a property that can be used inside an XML file to add additional new properties. * * @param attributeName the name of the property. This is the same name you'll be using to specify the property * type in the XML file. * @param function how the property should be processed. This converts the raw text input from the XML node value * into the correct object type. * @throws IllegalArgumentException when a property with this name is already registered. */ public static void registerProperty(@NotNull String attributeName, @NotNull Function<String, Object> function) { Pane.registerProperty(attributeName, function); } /** * Registers a name that can be used inside an XML file to add custom panes * * @param name the name of the pane to be used in the XML file * @param biFunction how the pane loading should be processed * @throws IllegalArgumentException when a pane with this name is already registered */ public static void registerPane(@NotNull String name, @NotNull BiFunction<Object, Element, Pane> biFunction) { if (PANE_MAPPINGS.containsKey(name)) { throw new IllegalArgumentException("pane name '" + name + "' is already registered"); } PANE_MAPPINGS.put(name, biFunction); } /** * Loads a pane by the given instance and node * * @param instance the instance * @param node the node * @return the pane */ @NotNull public static Pane loadPane(@NotNull Object instance, @NotNull Node node) { return PANE_MAPPINGS.get(node.getNodeName()).apply(instance, (Element) node); } /** * The gui state * * @since 0.4.0 */ public enum State { /** * This signals that only the top-half of the Gui is in use and the player's inventory will stay like it is * * @since 0.4.0 */ TOP, /** * This signals that the bottom-hal of the Gui is in use and the player's inventory will be cleared and stored * * @since 0.4.0 */ BOTTOM } static { registerPane("masonrypane", MasonryPane::load); registerPane("outlinepane", OutlinePane::load); registerPane("paginatedpane", PaginatedPane::load); registerPane("staticpane", StaticPane::load); registerPane("cyclebutton", CycleButton::load); registerPane("label", Label::load); registerPane("percentagebar", PercentageBar::load); registerPane("slider", Slider::load); registerPane("togglebutton", ToggleButton::load); } }