Declarative GUI
Experimental feature
This framework is currently experimental and may contain unknown issues. Please report bugs via GitHub Issues.
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 aStateobject. 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 insidesetState. 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.
import com.ultikits.ultitools.abstracts.gui.declarative.engine.DeclarativeGui;
import com.ultikits.ultitools.abstracts.gui.declarative.widgets.*;
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();
}
}
// Open the GUI
new MyFirstGui(player).open();4. Widget reference
4.1 Container
The basic container widget that holds other widgets and optionally provides a background.
Container.builder()
.background(IconWrapper.of(Material.GRAY_STAINED_GLASS_PANE)) // set background
.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.
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.
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
Ideal for rendering lists (shop items, inventories). GridView calculates row/column positions automatically.
GridView.<ShopItem>builder()
.startSlot(10) // starting slot
.columns(7) // columns per row
.rows(4) // max rows
.items(itemList, item -> {
// map data object to Widget
return ItemDisplay.builder(item.getStack())
.name(item.getName())
.onClick(() -> buy(item))
.build();
})
.build();5. State management & interaction
When a UI needs to change in response to user actions (pagination, selection), use StatefulWidget.
Example: simple counter
// 1. Define the Widget
public class CounterWidget extends StatefulWidget {
@Override
public State createState() {
return new CounterState();
}
}
// 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:
- user clicks the button
setStateupdatescount- framework marks
CounterWidgetdirty - framework calls
build()again - the
TextButtonis recreated withCount: 1 - 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.
ItemDisplay.builder(item)
.key(SlotKey.of("item-" + item.getId())) // unique id
.build();6.2 Navigation & routing
A Navigator lets you switch “pages” inside the same GUI window by swapping widget trees.
// in root build method
return new Navigator("home", Map.of(
"home", (context) -> new HomePageWidget(),
"settings", (context) -> new SettingsPageWidget()
));
// 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 finalwidgets 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:
currentPagecontrols data slicing - Single-select:
selectedSlothighlights selection - Interaction: buy button shows/hides based on selection
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 true; // cancel default behaviour to prevent item pickup
}
}