Skip to content

Declarative GUI

Experimental feature — render seams closed in v6.3.0

The repaint, click-dispatch and GridView positioning seams tracked in issue #200 are closed as of v6.3.0: build(BuildContext) now runs on every state change, a click outside the GUI's own inventory cannot reach a handler, and any widget type positions correctly inside a GridView. @ApiStatus.Experimental stays on the package for at least one more release, pending feedback from real servers running these rewritten mechanisms.

1. Introduction

Traditional Bukkit GUI development is usually imperative: you manually create an Inventory, set each ItemStack, and listen for InventoryClickEvent to handle interactions. As UI complexity grows this becomes hard to maintain and state management gets painful.

The UltiTools declarative GUI framework adopts the UI = f(State) idea:

  • Declarative: describe what the UI should look like for a given state; the framework figures out how to update the view.
  • Component-Based: the UI is composed of reusable, independent Widgets.
  • Reactive: when state changes, the framework updates the UI automatically.

Key benefits

  • Automatic diff updates: the framework diffs the widget tree and only updates changed slots — reducing packets and improving client performance.
  • State management: built-in state handling (similar to React/Flutter) makes pagination, selection and dynamic refresh easy to implement.
  • No manual listeners: click handlers attach to widgets directly — no global slot-based listener logic required.

2. Core concepts

2.1 Widget

A Widget is an immutable description of UI. Widgets are lightweight configuration objects.

  • StatelessWidget: no internal state — its appearance is fixed unless its parent rebuilds it. Use for static display elements like titles or background tiles.
  • StatefulWidget: holds a State object. When the state changes it can trigger a rebuild. Use for counters, paginated lists, toggles, etc.

2.2 State

A State object contains mutable data for a StatefulWidget.

  • setState(() -> { ... }): when you need to change data and refresh the UI you must do it inside setState. This marks the widget dirty and schedules a rebuild on the next frame.

2.3 BuildContext

BuildContext is the handle to a widget's position in the widget tree. It provides access to parent data, navigation, and other tree-level services.

3. Getting started

3.1 Create a simple GUI

All declarative GUIs extend DeclarativeGui.

java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

public class MyFirstGui extends DeclarativeGui {

    public MyFirstGui(Player player) {
        // Parameters: player, GUI id, title, rows
        super(player, "my_first_gui", "Hello GUI", 6);
    }

    @Override
    public Widget build(BuildContext context) {
        // Return the root widget
        return Container.builder()
            .child(
                TextButton.builder()
                    .text("Click Me!")
                    .color("GREEN")
                    .slot(13)
                    .onClick(() -> {
                        player.sendMessage("You clicked the button!");
                    })
                    .build()
            )
            .build();
    }
}
java
// Open the GUI
new MyFirstGui(player).open();

4. Widget reference

4.1 Container

Fill the background with explicit children

As of v6.3.0, Container.Builder has no background(...) method — the field it wrote in v6.2.5 was never read by anything on the render path, so v6.3.0 deletes it rather than leave it silently inert. Add an ItemDisplay child with an explicit slot for every cell you want filled, reusing one ItemStack such as a grey glass pane.

The basic container widget that holds other widgets.

java
Container.builder()
    .child(widget1) // add a single child
    .children(listWidgets) // add multiple children
    .build();

4.2 TextButton

A button with a colored pane and text — the primary interactive building block.

java
TextButton.builder()
    .text("Confirm")
    .color("LIME") // color name from UltiTools Colors
    .slot(22)
    .lore("Click to confirm", "Action cannot be undone")
    .onClick(() -> {
        // click handler
    })
    .build();

4.3 ItemDisplay

Displays an ItemStack and supports click handlers.

java
ItemDisplay.builder(itemStack)
    .slot(10)
    .name("My Sword") // override item name
    .lore("Damage: 100") // override item lore
    .onClick(event -> {
        // event is InventoryClickEvent
    })
    .build();

4.4 GridView

Any widget type positions correctly

As of v6.3.0, GridView writes each child's computed slot as parent data at render time, so any widget type — not only ItemDisplay — auto-positions into its row/column slot; Widget's own API is unchanged, so no downstream custom widget needs a change. An explicit .slot(...) on a child inside a GridView is overridden and a WARNING names the child; a child with no explicit slot, or legitimately declared at slot 0, produces no warning.

Ideal for rendering lists (shop items, inventories). Any widget type is automatically positioned into row/column slots.

java
GridView.<ShopItem>builder()
    .startSlot(10) // starting slot
    .columns(7)    // columns per row
    .items(itemList, item -> {
        // map data object to Widget
        return ItemDisplay.builder(item.getStack())
            .name(item.getName())
            .onClick(() -> buy(item))
            .build();
    })
    .build();

GridView.Builder.rows(int) is removed

As of v6.3.0, .rows(int)/getMaxRows() no longer exist — the field they wrote was never read by anything, so v6.3.0 deletes them rather than implement an overflow rule nothing asked for. Truncate the list to the row count you want, in your own code, before passing it to .items(...).

5. State management & interaction

When a UI needs to change in response to user actions (pagination, selection), use StatefulWidget.

Example: simple counter

java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

// 1. Define the Widget
public class CounterWidget extends StatefulWidget {
    @Override
    public State createState() {
        return new CounterState();
    }
}
java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

// 2. Define the State
public class CounterState extends State<CounterWidget> {
    private int count = 0; // persistent across rebuilds

    @Override
    public Widget build(BuildContext context) {
        return TextButton.builder()
            .slot(13)
            .text("Count: " + count)
            .color("BLUE")
            .onClick(() -> {
                // 3. update state inside setState
                setState(() -> {
                    count++;
                });
            })
            .build();
    }
}

How it works:

  1. user clicks the button
  2. setState updates count
  3. framework marks CounterWidget dirty
  4. framework calls build() again
  5. the TextButton is recreated with Count: 1
  6. the diff algorithm detects the name change at slot 13 and updates only that slot (no full refresh)

6. Advanced topics

6.1 Importance of SlotKey

When rendering dynamic lists (e.g. GridView) assign a unique key to each item so the diff algorithm can detect moves instead of delete+create.

Now actually preserves state across a reorder

Before v6.3.0, ContainerElement and GridViewElement paired children by list position only, so a SlotKey had no effect and reordering a keyed list still discarded every item's State. As of v6.3.0, both classes reconcile children by SlotKey first, falling back to position only for children with no key.

java
ItemDisplay.builder(item)
    .key(SlotKey.of("item-" + item.getId())) // unique id
    .build();

6.2 Navigation & routing

Pushing a route now repaints immediately

Before v6.3.0, push(String) applied its change through setState, which marked the element dirty without ever scheduling a build, so the route was pushed onto the history while the open GUI kept showing the previous page. As of v6.3.0, every setState-driven dirty mark reaches a scheduled repaint, so pushing a route updates the visible page immediately.

Navigator.of(context) can return null

Navigator.of(context) is @Nullable and returns null when no Navigator sits above the current element, so a chained call throws NullPointerException. Check the result for null before calling .push(...)/.pop()/.pushReplacement(...) on it.

A Navigator lets you switch “pages” inside the same GUI window by swapping widget trees.

java
// in root build method
Map<String, RouteBuilder> routes = new HashMap<>();
routes.put("home", (context) -> new HomePageWidget());
routes.put("settings", (context) -> new SettingsPageWidget());
return new Navigator("home", routes);

// push from child
Navigator.of(context).push("settings");

6.3 Performance tips

  • Avoid heavy work in build: build() may run frequently — don’t perform DB calls or expensive computations there.
  • Extract constant widgets: reuse static final widgets for parts that never change (e.g. background tiles).
  • Localize refresh: push state down to leaf nodes so only small parts of the tree rebuild (make a single button stateful rather than the whole page).

7. Full example: shop page

  • Layout: Container + GridView
  • Pagination: currentPage controls data slicing
  • Single-select: selectedSlot highlights selection
  • Interaction: buy button shows/hides based on selection
java
package com.ultikits.docs.declarative;

import com.ultikits.ultitools.abstracts.gui.declarative.core.BuildContext;
import com.ultikits.ultitools.abstracts.gui.declarative.core.State;
import com.ultikits.ultitools.abstracts.gui.declarative.core.StatefulWidget;
import com.ultikits.ultitools.abstracts.gui.declarative.core.Widget;
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
import com.ultikits.ultitools.abstracts.gui.declarative.util.SlotUtils;
import com.ultikits.ultitools.entities.Colors;
import com.ultikits.ultitools.utils.XVersionUtils;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

public class ExampleShopPage extends DeclarativeGui {

    private final List<ShopItem> items;
    private int currentPage = 0;
    private int selectedSlot = -1;
    
    private static final int ITEMS_PER_PAGE = 28; // 4 rows x 7 columns
    private static final int START_SLOT = 10;     // start at row 2, column 2

    /**
     * Shop item data class.
     */
    public static class ShopItem {
        private final ItemStack display;
        private final double price;
        private final String name;

        public ShopItem(ItemStack display, double price, String name) {
            this.display = display;
            this.price = price;
            this.name = name;
        }

        public ItemStack getDisplay() {
            return display;
        }

        public double getPrice() {
            return price;
        }

        public String getName() {
            return name;
        }
    }

    /**
     * Create the shop page.
     *
     * @param player viewer
     * @param items  item list
     */
    public ExampleShopPage(@NotNull Player player, @NotNull List<ShopItem> items) {
        super(player, "example_shop", "§6§lItem Shop", 6);
        this.items = new ArrayList<>(items);
    }

    @Override
    @NotNull
    public Widget build(@NotNull BuildContext context) {
        List<Widget> children = new ArrayList<>();

        // 1. title
        children.add(createTitle());

        // 2. decorative borders
        children.addAll(createBorders());

        // 3. item grid
        children.add(createItemGrid());

        // 4. pagination controls
        children.add(createPaginationControls());

        // 5. selected item info (if any)
        if (selectedSlot >= 0) {
            children.add(createSelectedInfo());
        }

        // 6. close button
        children.add(createCloseButton());

        return Container.builder()
                .children(children)
                .build();
    }

    /** Create title button. */
    @NotNull
    private Widget createTitle() {
        return TextButton.builder()
                .text("§6§lItem Shop")
                .color("YELLOW")
                .slot(4)
                .build();
    }

    /** Create decorative borders. */
    @NotNull
    private List<Widget> createBorders() {
        List<Widget> borders = new ArrayList<>();
        ItemStack borderGlass = XVersionUtils.getColoredPlaneGlass(Colors.GRAY);

        // top and bottom borders
        for (int col = 0; col < 9; col++) {
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(col)
                    .build());
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(45 + col)
                    .build());
        }

        // left and right borders
        for (int row = 1; row < 5; row++) {
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(row * 9)
                    .build());
            borders.add(ItemDisplay.builder(borderGlass)
                    .slot(row * 9 + 8)
                    .build());
        }

        return borders;
    }

    /** Create the item grid widget. */
    @NotNull
    private Widget createItemGrid() {
        List<ShopItem> pageItems = getPageItems();
        
        List<Widget> itemWidgets = new ArrayList<>();
        for (int i = 0; i < pageItems.size(); i++) {
            ShopItem item = pageItems.get(i);
            int slot = calculateItemSlot(i);
            boolean isSelected = (slot == selectedSlot);

            itemWidgets.add(createItemWidget(item, slot, isSelected));
        }

        return Container.builder()
                .children(itemWidgets)
                .build();
    }

    /** Create a single item widget. */
    @NotNull
    private Widget createItemWidget(@NotNull ShopItem item, int slot, boolean isSelected) {
        ItemStack display = item.getDisplay().clone();
        
        // optionally add visual effect when selected
        if (isSelected) {
            // add selection effect here
        }

        return ItemDisplay.builder(display)
                .slot(slot)
                .name("§e" + item.getName())
                .lore(
                        "§7Price: §6$" + item.getPrice(),
                        "",
                        isSelected ? "§a§lSELECTED" : "§eClick to select"
                )
                .onClick(() -> selectItem(slot))
                .key("item-" + slot)
                .build();
    }

    /** Create pagination controls. */
    @NotNull
    private Widget createPaginationControls() {
        List<Widget> controls = new ArrayList<>();

        // previous page
        if (currentPage > 0) {
            controls.add(TextButton.builder()
                    .text("§a← Previous")
                    .color("GREEN")
                    .slot(45)
                    .onClick(this::goToPreviousPage)
                    .build());
        }

        // page indicator
        int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
        controls.add(TextButton.builder()
                .text("§7Page §f" + (currentPage + 1) + "§7/§f" + totalPages)
                .color("GRAY")
                .slot(49)
                .build());

        // next page
        if (currentPage < totalPages - 1) {
            controls.add(TextButton.builder()
                    .text("§aNext →")
                    .color("GREEN")
                    .slot(53)
                    .onClick(this::goToNextPage)
                    .build());
        }

        return Container.builder()
                .children(controls)
                .build();
    }

    /** Create selected item info display. */
    @NotNull
    private Widget createSelectedInfo() {
        ShopItem selected = getSelectedItem();
        if (selected == null) {
            return Container.builder().build();
        }

        return TextButton.builder()
                .text("§aBuy: §f" + selected.getName())
                .color("LIME")
                .slot(47)
                .lore("§7Price: §6$" + selected.getPrice())
                .onClick(this::buySelectedItem)
                .build();
    }

    /** Create close button. */
    @NotNull
    private Widget createCloseButton() {
        return TextButton.builder()
                .text("§c§lClose")
                .color("RED")
                .slot(51)
                .onClick(() -> player.closeInventory())
                .build();
    }

    // ========== business logic ==========

    /** Get items for the current page. */
    @NotNull
    private List<ShopItem> getPageItems() {
        int start = currentPage * ITEMS_PER_PAGE;
        int end = Math.min(start + ITEMS_PER_PAGE, items.size());
        
        if (start >= items.size()) {
            return new ArrayList<>();
        }
        return items.subList(start, end);
    }

    /** Calculate the slot index for the item at `index`. */
    private int calculateItemSlot(int index) {
        int row = index / 7;  // 7 items per row
        int col = index % 7;
        return SlotUtils.toSlotIndex(START_SLOT, row, col);
    }

    /** Select an item. */
    private void selectItem(int slot) {
        setState(() -> {
            selectedSlot = slot;
        });
    }

    /** Get the currently selected ShopItem, or null. */
    private ShopItem getSelectedItem() {
        if (selectedSlot < 0) {
            return null;
        }
        
        // compute index within current page
        int indexInPage = -1;
        List<ShopItem> pageItems = getPageItems();
        for (int i = 0; i < pageItems.size(); i++) {
            if (calculateItemSlot(i) == selectedSlot) {
                indexInPage = i;
                break;
            }
        }
        
        if (indexInPage < 0 || indexInPage >= pageItems.size()) {
            return null;
        }
        
        return pageItems.get(indexInPage);
    }

    /** Buy the selected item. */
    private void buySelectedItem() {
        ShopItem selected = getSelectedItem();
        if (selected == null) {
            return;
        }

        // add actual purchase logic here (balance check, give item, etc.)
        player.sendMessage("§aYou bought §f" + selected.getName() + " §afor §6$" + selected.getPrice());
        
        // clear selection after purchase
        setState(() -> {
            selectedSlot = -1;
        });
    }

    /** Go to previous page. */
    private void goToPreviousPage() {
        if (currentPage > 0) {
            setState(() -> {
                currentPage--;
                selectedSlot = -1;  // clear selection when page changes
            });
        }
    }

    /** Go to next page. */
    private void goToNextPage() {
        int totalPages = (int) Math.ceil((double) items.size() / ITEMS_PER_PAGE);
        if (currentPage < totalPages - 1) {
            setState(() -> {
                currentPage++;
                selectedSlot = -1;  // clear selection when page changes
            });
        }
    }

    // ========== lifecycle hooks ==========

    @Override
    protected void onGuiOpen(@NotNull InventoryOpenEvent event) {
        player.sendMessage("§aWelcome to the shop!");
    }

    @Override
    protected void onGuiClose(@NotNull InventoryCloseEvent event) {
        // cleanup
    }

    @Override
    protected boolean onGuiClick(@NotNull InventoryClickEvent event) {
        // extra click handling (if needed)
        return false;  // keep the event cancelled so the item cannot be picked up
    }
}

Contributors

The avatar of contributor named as Ling Bao Ling Bao
The avatar of contributor named as Claude Fable 5.1 Claude Fable 5.1

Released under the MIT License.