Skip to content

OkaeriPoland/okaeri-menu

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

91 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Okaeri Menu

License Discord

Modern, reactive GUI framework for Minecraft (Paper) with pane-based architecture, async data loading, and advanced filtering.

Features

  • Pane-Based Architecture: Organize menus into reusable, composable panes with precise bounds
  • Reactive Properties: Menu items automatically update when data changes
  • Async Support: Load data asynchronously with built-in loading/error/empty states
  • Smart Pagination: Filter, sort, and paginate large datasets with database-side filtering
  • Modern API: Fluent builders, lambdas, and type-safe design
  • i18n Ready: Built-in MiniMessage support with multi-locale text formatting

Requirements

Platform

  • Paper 1.21+ (or any modern Paper fork like Purpur)
    • Older Paper versions may work but are not officially supported
    • Some features may behave differently or break on older versions
    • Test thoroughly if using older versions
  • Spigot/Bukkit: Not officially supported
    • Adventure API must be shaded and relocated into your plugin
    • See Adventure Platform Bukkit for setup
    • Relocation of Adventure is required to avoid conflicts

Java

  • Java 21 or higher required

Installation

Version Java Paper

Maven

<repositories>
    <repository>
        <id>okaeri-repo</id>
        <url>https://storehouse.okaeri.eu/repository/maven-public/</url>
    </repository>
</repositories>
<dependency>
    <groupId>eu.okaeri</groupId>
    <artifactId>okaeri-menu-bukkit</artifactId>
    <version>2.0.1-beta.3</version>
</dependency>

Gradle (Kotlin DSL)

repositories {
    maven("https://storehouse.okaeri.eu/repository/maven-public/")
}
dependencies {
    implementation("eu.okaeri:okaeri-menu-bukkit:2.0.1-beta.3")
}

Quick Start

Simple Static Menu

import eu.okaeri.menu.Menu;
import org.bukkit.Material;
import org.bukkit.plugin.Plugin;

import static eu.okaeri.menu.item.MenuItem.item;
import static eu.okaeri.menu.pane.StaticPane.staticPane;

public class SimpleMenuExample {

    public static Menu createMenu(Plugin plugin) {
        return Menu.builder(plugin)
            .title("<green>My First Menu")
            .rows(3)  // Optional - auto-calculated from pane bounds if omitted
            .pane(staticPane("main")
                .bounds(0, 0, 3, 9)  // row, col, height, width
                .item(1, 4, item()   // row, col
                    .material(Material.DIAMOND)
                    .name("<aqua><b>Click Me!")
                    .lore("""
                        <gray>This is a clickable item
                        <yellow>Try it out!""")
                    .onClick(ctx -> {
                        ctx.sendMessage("<green>You clicked the diamond!");
                        ctx.playSound(Sound.ENTITY_EXPERIENCE_ORB_PICKUP);
                    })
                    .build())
                .item(2, 8, item()   // row, col
                    .material(Material.BARRIER)
                    .name("<red>Close")
                    .onClick(ctx -> ctx.closeInventory())
                    .build())
                .build())
            .build();
    }
}

Reactive Menu with Dynamic Updates

public static Menu createReactiveMenu(Plugin plugin) {
    return Menu.builder(plugin)
        .title("<yellow>Reactive Menu")
        // .state(s -> s.define("clickCount", 0))  // Optional - integers default to 0
        .pane(staticPane("main")
            .bounds(0, 0, 3, 9)  // row, col, height, width
            .item(1, 4, item()   // row, col
                .material(ctx -> (ctx.getInt("clickCount") > 10) ? Material.DIAMOND : Material.COAL)
                .name("<gold>Clicks: <count>")
                .vars(ctx -> Map.of("count", ctx.getInt("clickCount")))
                .lore(ctx -> (ctx.getInt("clickCount") > 10)
                    ? "<green>You're a clicking master!"
                    : "<gray>Keep clicking...")
                .onClick(ctx -> {
                    ctx.set("clickCount", ctx.getInt("clickCount") + 1);
                    // No refresh needed - state changes automatically trigger refresh on next tick!
                })
                .build())
            .build())
        .build();
}

Automatic Refresh: State changes (ctx.set()), async data updates, and pagination changes automatically trigger refresh on the next tick.

Lazy Computation & Computed Values

Use .lazy() to compute expensive values once per player and reuse them with .computed():

public static Menu createPlayerStatsMenu(Plugin plugin) {
    return Menu.builder(plugin)
        // Lazy: cached until ctx.invalidate() - for user-controlled changes
        .lazy("playerRank", ctx -> calculatePlayerRank(ctx.getPlayer()))
        // Reactive: auto-refreshes every 5 seconds - for external changes
        .reactive("onlinePlayers", ctx -> Bukkit.getOnlinePlayers().size(), Duration.ofSeconds(5))
        // Use computed values in reactive title
        .title(ctx -> {
            String rank = ctx.computed("playerRank").orElse("Unknown");
            int online = ctx.computed("onlinePlayers").orElse(0);
            return "<gold>Stats | Rank: " + rank + " | Online: " + online;
        })
        .pane(staticPane("stats")
            .bounds(0, 0, "P . . . Y . . . U")  // 1 row × 9 columns
            .item('P', item()
                .material(Material.PLAYER_HEAD)
                .name("<yellow>Online Players: <count>")
                .vars(ctx -> Map.of("count", ctx.computed("onlinePlayers").orElse(0)))
                .build())
            .item('Y', item()
                .material(Material.DIAMOND)
                .name("<aqua>Your Rank: <rank>")
                .vars(ctx -> Map.of("rank", ctx.computed("playerRank").orElse("Unknown")))
                .build())
            .item('U', item()
                .material(Material.EMERALD)
                .name("<green>Premium Upgrade")
                .lore("<gray>Unlock exclusive features!")
                // Functional style: hide if PREMIUM or not yet loaded
                .visible(ctx -> ctx.computed("playerRank")
                    .map(rank -> !"PREMIUM".equals(rank))
                    .orElse(false))  // Hidden while loading/error/empty
                .onClick(ctx -> {
                    purchasePremium(ctx.getPlayer());
                    ctx.invalidate();  // Mark lazy values for recomputation on next refresh
                })
                .build())
            .build())
        .build();
}

Key Benefits:

  • Performance: Expensive operations (DB queries, calculations) run once, not on every refresh
  • Reusability: Access computed values in title, visibility conditions, display text, etc.
  • Type-safe: ctx.computed("key", Type.class) for typed retrieval

Lazy vs Reactive:

  • .lazy() - No TTL, cached until explicitly invalidated with ctx.invalidate()
  • .reactive() - Has TTL (default 1 second), automatically re-computes when expired
  • Use .lazy() for values that only change on user action (rank, purchases, manual updates)
  • Use .reactive() for values that change externally (balance, online players, server stats)

Update Interval: Only needed for item properties (.name(), .visible(), etc.) that directly access external sources without caching (e.g., ctx -> player.getLevel()). If properties use ctx.computed() or state API (ctx.getInt()), those have their own update mechanisms—no updateInterval needed! For values with TTL (.reactive()), the TTL handles updates automatically.

Opening Menus

// Immediate open (shows loading states for async components)
menu.open(player);

// Async open with data preloading - wait up to 5 seconds
menu.open(player, Duration.ofSeconds(5))
    .thenAccept(result -> {
        if (result.isSuccess()) {
            player.sendMessage("Menu opened!");

            // Check data quality
            if (result.isFullyLoaded()) {
                player.sendMessage("All data loaded successfully!");
            } else if (result.getStatus() == OpenStatus.TIMEOUT) {
                player.sendMessage("Menu opened on timeout (partial data)");
            }
        } else {
            player.sendMessage("Failed to open menu: " + result.getStatus());
        }
    });

// Simple usage - just check success
menu.open(player, Duration.ofSeconds(5))
    .thenAccept(result -> {
        if (result.isSuccess()) {
            player.sendMessage("Menu opened in " + result.getElapsed().toMillis() + "ms");
        }
    });

// Blocking usage (not recommended for main thread)
MenuOpenResult result = menu.open(player, Duration.ofSeconds(5)).join();
if (result.getStatus() == OpenStatus.PRELOADED) {
    player.sendMessage("All data preloaded!");
}

Advanced Features

Pagination with Controls

import static eu.okaeri.menu.pane.PaginatedPane.pane;

public static Menu createShopMenu(Plugin plugin) {
    return Menu.builder(plugin)
        .title("<yellow>⚡ Shop")
        .pane(pane("items", ShopItem.class)
            .bounds(1, 0, 4, 9)  // row, col, height, width
            .items(() -> loadShopItems())  // Your data source
            .renderer((ctx, item, index) -> item()
                .material(item.getMaterial())
                .name("<yellow><name>")
                .vars(Map.of(
                    "name", item.getName(),
                    "price", item.getPrice()
                ))
                .lore("<gray>Price: <gold><price> coins")
                .onClick(ctx -> purchaseItem(ctx.getEntity(), item))
                .build())
            .build())
        .pane(staticPane("controls")
            .bounds(5, 0, ". . < . I . > . .")  // Template: dimensions auto-derived
            .item('<', previousPageButton("items").build())
            .item('I', pageIndicator("items").build())
            .item('>', nextPageButton("items").build())
            .build())
        .build();
}

Async Items Loading

import static eu.okaeri.menu.pane.AsyncPaginatedPane.paneAsync;

public static Menu createAsyncShopMenu(Plugin plugin) {
    return Menu.builder(plugin)
        .title("<yellow>⚡ Async Shop")
        .pane(paneAsync("items", ShopItem.class)
            .bounds(1, 0, 4, 9)  // row, col, height, width
            .loader(ctx -> {
                // Runs async - fetch from database
                int page = ctx.getCurrentPage();
                int pageSize = ctx.getPageSize();

                // NOTE: Inline SQL for demonstration - use your actual database layer
                return database.query("""
                    SELECT * FROM shop_items
                    LIMIT ? OFFSET ?
                    """, pageSize + 1, page * pageSize);
            })
            .ttl(Duration.ofSeconds(30))  // Cache for 30 seconds
            .renderer((ctx, item, index) -> item()
                .material(item.getMaterial())
                .name("<yellow><name>")
                .vars(Map.of(
                    "name", item.getName(),
                    "price", item.getPrice()
                ))
                .lore("<gray>Price: <gold><price> coins")
                .build())
            .loading(loadingItem(Material.HOPPER, "<gray>Loading...").build())
            .error(errorItem(Material.BARRIER, "<red>Failed to load").build())
            .empty(emptyItem(Material.CHEST, "<gray>No items").build())
            .build())
        .build();
}

Filtering Items

// Filters are declared on menu items (not on the pane)
// State is per-player - each viewer has independent filter state
// No .state() block needed - booleans default to false
Menu.builder(plugin)
    .title("<yellow>Shop with Filters")
    // Filter controls - toggle items with declarative filters
    .pane(staticPane("controls")
        .bounds(0, 0, 1, 9)  // row, col, height, width
        .item(0, 2, item()   // row, col
            .material(ctx -> ctx.getBool("weaponFilter") ? Material.DIAMOND_SWORD : Material.WOODEN_SWORD)
            .name(ctx -> ctx.getBool("weaponFilter") ? "<green>✓ Weapons" : "<gray>Weapons")
            .lore("<gray>Show weapons only\n<yellow>Click to toggle!")
            // Declarative filter - attached to this menu item
            .filter(ItemFilter.<ShopItem>builder()
                .target("items")  // Target pane name
                .id("weapon")
                .when(ctx -> ctx.getBool("weaponFilter"))  // Active when true
                .predicate(item -> item.getCategory().equals("weapon"))
                .build())
            .onClick(ctx -> {
                ctx.set("weaponFilter", !ctx.getBool("weaponFilter"));
                ctx.playSound(Sound.UI_BUTTON_CLICK);
            })
            .build())
        .item(0, 4, item()   // row, col
            .material(ctx -> ctx.getBool("expensiveFilter") ? Material.GOLD_INGOT : Material.IRON_INGOT)
            .name(ctx -> ctx.getBool("expensiveFilter") ? "<green>✓ Expensive" : "<gray>Expensive")
            .lore("<gray>Show items over 100 coins\n<yellow>Click to toggle!")
            .filter(ItemFilter.<ShopItem>builder()
                .target("items")
                .id("expensive")
                .when(ctx -> ctx.getBool("expensiveFilter"))
                .predicate(item -> item.getPrice() > 100)
                .build())
            .onClick(ctx -> {
                ctx.set("expensiveFilter", !ctx.getBool("expensiveFilter"));
                ctx.playSound(Sound.UI_BUTTON_CLICK);
            })
            .build())
        .build())
    // Items pane - filters are applied automatically
    .pane(pane("items" ShopItem.class)
        .bounds(1, 0, 4, 9)  // row, col, height, width
        .items(() -> loadShopItems())
        .renderer((ctx, item, index) -> item()
            .material(item.getMaterial())
            .name("<yellow><name>")
            .vars(Map.of("name", item.getName(), "price", item.getPrice()))
            .lore("<gray>Price: <gold><price> coins")
            .build())
        .build())
    .build();

Database-Side Filtering with Value Filters

For async loaders, use value-only filters to pass filter parameters to your database queries without loading all data into memory first.

// No .state() block needed - primitives have automatic defaults (false, 0)
Menu.builder(plugin)
    .title("<yellow>Database-Filtered Shop")
    // Filter controls with value-only filters
    .pane(staticPane("controls")
        .bounds(0, 0, 1, 9)  // row, col, height, width
        .item(0, 2, item()   // row, col
            .material(ctx -> ctx.getBool("weaponFilter") ? Material.DIAMOND_SWORD : Material.WOODEN_SWORD)
            .name(ctx -> ctx.getBool("weaponFilter") ? "<green>✓ Weapons" : "<gray>Weapons")
            .lore("<gray>Filter by category\n<yellow>Click to toggle!")
            // Value-only filter (no predicate) - passes value to database query
            .filter(ItemFilter.<ShopItem>builder()
                .target("items")
                .id("category")
                .when(ctx -> ctx.getBool("weaponFilter"))
                .value(() -> "WEAPONS")  // Value passed to loader
                .build())
            .onClick(ctx -> ctx.set("weaponFilter", !ctx.getBool("weaponFilter")))
            .build())
        .item(0, 4, item()   // row, col
            .material(Material.GOLD_INGOT)
            .name("<yellow>Min Price: <white><price>")
            .vars(ctx -> Map.of("price", ctx.getInt("minPrice")))
            .lore("<gray>Filter by minimum price\n<yellow>Click: +50 | Shift+Click: Reset")
            .filter(ItemFilter.<ShopItem>builder()
                .target("items")
                .id("minPrice")
                .when(ctx -> ctx.getInt("minPrice") > 0)
                .value(ctx -> ctx.getInt("minPrice"))  // Integer value
                .build())
            .onClick(ctx -> {
                if (ctx.isShiftClick()) {
                    ctx.set("minPrice", 0);
                } else {
                    ctx.set("minPrice", ctx.getInt("minPrice") + 50);
                }
            })
            .build())
        .build())
    // Async pane - filters are extracted in loader
    .pane(paneAsync("items", ShopItem.class)
        .bounds(1, 0, 4, 9)  // row, col, height, width
        .loader(ctx -> {
            // Extract filter values from context
            String category = ctx.getFilter("category", String.class).orElse(null);
            Integer minPrice = ctx.getFilter("minPrice", Integer.class).orElse(0);

            // Build database query with filters
            // NOTE: Use your actual database layer, not inline SQL
            StringBuilder query = new StringBuilder("SELECT * FROM shop_items WHERE 1=1");
            List<Object> params = new ArrayList<>();

            if (category != null) {
                query.append(" AND category = ?");
                params.add(category);
            }
            if (minPrice > 0) {
                query.append(" AND price >= ?");
                params.add(minPrice);
            }

            query.append(" LIMIT ? OFFSET ?");
            params.add(ctx.getPageSize() + 1);
            params.add(ctx.getCurrentPage() * ctx.getPageSize());

            return database.query(query.toString(), params.toArray());
        })
        .ttl(Duration.ofSeconds(30))
        .renderer((ctx, item, index) -> item()
            .material(item.getMaterial())
            .name("<yellow><name>")
            .vars(Map.of("name", item.getName(), "price", item.getPrice()))
            .lore("<gray>Price: <gold><price> coins")
            .build())
        .build())
    .build();

Key concepts:

  • Value-only filters: Use .value(() -> ...) without .predicate() on the filter
  • LoaderContext API: Access filter values in your loader with ctx.getFilter(id, Type.class)
  • Database queries: Use filter values to build WHERE clauses for efficient database-side filtering
  • Type-safe: Filter values are retrieved with type checking (String.class, Integer.class, etc.)

Per-Player State Management

Each player viewing a menu has independent state. Use the state API to store per-player data like filter toggles, counters, or selections.

Menu.builder(plugin)
    .title("<yellow>Stateful Menu")
    // Define menu-level defaults (optional)
    .state(s -> s
        .define("clicks", 0)
        .define("favoriteColor", "blue")
        .define("premium", false))
    .pane("main", staticPane()
        .bounds(0, 0, 3, 9)  // row, col, height, width
        .item(1, 4, item()   // row, col
            // Get state with automatic defaults
            .name(ctx -> "<gold>Clicks: " + ctx.getInt("clicks"))
            .onClick(ctx -> {
                // Set state - automatically triggers refresh on next tick
                ctx.set("clicks", ctx.getInt("clicks") + 1);
            })
            .build())
        .build())
    .build();

State API Methods:

// Primitive convenience methods (with automatic defaults)
int clicks = ctx.getInt("clicks");              // Returns 0 if not set
boolean premium = ctx.getBool("premium");       // Returns false if not set
String name = ctx.getString("name");            // Returns null if not set

// Generic get/set
Integer value = ctx.get("key", Integer.class);
ctx.set("key", 42);

// Collections
List<String> names = ctx.getList("names");
Map<String, Integer> scores = ctx.getMap("scores");

// Check if explicitly set (not just default)
if (ctx.has("customValue")) {
    // Player has explicitly set this value
}

// Remove state (reverts to default)
ctx.remove("temporaryData");

Default Value Priority:

  1. Explicitly set value via ctx.set()
  2. Menu-level default from .state() block
  3. Automatic primitive default (0, false, etc.)
  4. null for non-primitive types

Automatic Refresh Behavior:

State changes automatically trigger refreshes on the next tick:

  • State changes (ctx.set()) → refresh on next tick
  • Pagination changes (page navigation, filters) → refresh on next tick

Examples

Check out test-plugin for comprehensive examples:


"Nooo, I can't use Paper!" 😱

Relax. It's annoying, but here's how to make it probably work on Spigot/Bukkit:

Maven:

<dependencies>
    <dependency>
        <groupId>net.kyori</groupId>
        <artifactId>adventure-platform-bukkit</artifactId>
        <version>4.3.2</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.5.1</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals><goal>shade</goal></goals>
                </execution>
            </executions>
            <configuration>
                <relocations>
                    <relocation>
                        <pattern>net.kyori</pattern>
                        <shadedPattern>com.yourplugin.libs.adventure</shadedPattern>
                    </relocation>
                </relocations>
            </configuration>
        </plugin>
    </plugins>
</build>

Gradle (Kotlin DSL):

plugins {
    id("com.github.johnrengelman.shadow") version "8.1.1"
}

dependencies {
    implementation("net.kyori:adventure-platform-bukkit:4.3.2")
}

tasks.shadowJar {
    relocate("net.kyori", "com.yourplugin.libs.adventure")
}

Replace com.yourplugin with your plugin's package name. Relocation prevents conflicts with other plugins.

About

Modern, reactive GUI framework for Minecraft (Paper) with pane-based architecture, async data loading, and advanced filtering.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages