Skip to content

Screens

Pétrus Pradella edited this page Nov 2, 2026 · 14 revisions

Screens

What this page covers: the screen framework in br.com.finalcraft.evernifecore.screen (:common) - a Screen described once and drawn by whichever renderer serves the player, its elements (Button, Label, PagedList, InputField, ProgressBar, Choice), click rules over Gate and effects, reactivity, the ScreenRenderer SPI, the four override layers of Screens.open(player, key, request), the core's default screens, and ScreenWorld for tests. The last part, The chest renderer, is the Bukkit side (minecraft.screen, :minecraft): ChestKind, editable areas, ItemStore and StoredInventory.


The one idea to hold on to

A Screen is a description: a grid, a title, elements on slots. It owns no window. Each player who opens it gets a ScreenView - their own copy of every state, cycle position and page - drawn by a ScreenRenderer chosen for that player and that screen. On Bukkit that renderer is the chest (evernifecore:chest); another platform, or a client mod, plugs in its own without the screen changing.

flowchart LR
    S["Screen<br/>(description, :common)"] -->|"open(player)"| R{"highest priority renderer<br/>that supports the player<br/>and covers the capabilities"}
    R --> V["ScreenView<br/>(one per viewer)"]
    V -->|"ElementPatch list, once per tick"| SF["ScreenSurface<br/>(chest, mod, page...)"]
Loading

What a slot looks like is an Icon (an ItemSpec, fallback candidates, raw item-data lines, named states, per-language text). What a slot does is an element with a stable id. Icon carries no click and no visibility rule; the element does.


A screen in code

import br.com.finalcraft.evernifecore.screen.Icon;
import br.com.finalcraft.evernifecore.screen.Layout;
import br.com.finalcraft.evernifecore.screen.Screen;

Screen<Layout> screen = Screen.of(3).title("§8Shop");          // three rows of nine
screen.icon(13, Icon.of("minecraft:diamond").displayName("§bBuy a diamond"))
        .onClick(click -> click.getViewer().sendMessage("bought"));
screen.open(fPlayer);                                            // CompletableFuture<ScreenView>
  • Screen.of(int rows), Screen.of(Grid), Screen.of(MyLayout.class) / Screen.of(layoutInstance).
  • icon(slot | SlotSet, Icon) answers a Button (id slot#<n> unless .id("buy")), so declare the screen in a variable and chain on the button. Slots.of/at/box/row/column/border/all build slot sets.
  • title(String) or title(Supplier<String>) (re-read on every pass; only a renderer with TITLE_CHANGE shows the change).
  • open(FPlayer viewer, Object... args) - the args fill {0}..{n} in every text.
  • Also on Screen: key(ScreenKey), owner(ECPluginData) (the caller logic runs as), addReplacer(...), subject(IPlayerData), policy(ClickPolicy), debounce(millis) (200 ms by default), onClose(Consumer<CloseContext<L>>), onMissingCapability(ScreenFallback), addComponent(...).

The viewer is always an FPlayer: ClickContext.getViewer() and CloseContext.getViewer() answer one.

Elements

Element Declared by What it is
Button icon(slot, icon), icon(l -> l.FIELD) a click (onClick, rule(...)), a state (state, states, cycle), visibleWhen(Predicate<ScreenView>) (or a Supplier<Boolean>), every(ticks)/render(icon -> ...), dependsOn(...)
Label label(l -> l.FIELD), label(slots, icon) text only; a renderer that draws by element shows it as a label
PagedList<T> list(source) one entry per slot of a region, paginated by the region's size; entries are <id>#<n>, arrows <id>#previous/<id>#next
InputField input(l -> l.FIELD) or input(slots, icon), then .onInput(spec, (view, value) -> ...) a value typed by the viewer: every renderer asks through PromptRegistry and gives the screen back
ProgressBar progress(l -> l.FIELD, State<Double>), progress(slots, icon, State<Double>) a ratio 0..1; the patch carries it, and the icon text reads {progress} as a whole percentage
Choice choice(l -> l.FIELD, text, gate, effects), choice(slots, icon, text, gate, effects) a dialogue option: each redraw checks its Gate once - a pass draws its costs as the last lore line, a failure draws it disabled with every reason, one per line (or not at all after hiddenUntilPassed()); a name with no colour of its own reads green or grey to match. Picking it pays the gate and runs the effects - or, after pickedBy(click -> ...), hands the pick to an engine that pays it (Dialogues)

Element ids are checked when a view opens: two elements under one id refuse the open and name both.

PagedList<Kit, MyLayout> kits = screen.list(() -> kitService.all())
        .id("kits")
        .into(l -> l.ENTRY)                       // or into(Slots.box(2, 2, 4, 8))
        .pagedBy(l -> l.PREVIOUS, l -> l.NEXT)   // or pagedBy(Slots.of(36), Slots.of(44)).arrows(prev, next)
        .render((kit, icon) -> icon.displayName("§e" + kit.getName()))
        .onClick((click, kit) -> kit.give(click.getViewer()));

list(...) also takes a List<T> or a PagedList.PageSource<T> (a page read on demand, total(...) for the count). A Pager handed to pager(pager) reads and turns the page from outside. Arrows placed on raw slots by pagedBy(SlotSet, SlotSet) carry no icon of their own: arrows(Icon previous, Icon next) gives them one (a null side keeps the framework's arrow; called after the layout form, it replaces the layout icons).


Clicks: ClickRule and ClickContext

A click runs the button's ClickRules first, then its plain onClick. A rule is a Gate (conditions + costs, see Logic) plus the effects of success and of denial, for the gestures it names:

screen.icon(l -> l.BUY).rule(ClickRule.on(ClickGesture.PRIMARY)
        .gate(Gate.builder().require(hasLevel10).cost(fiveCoins).build())
        .onSuccess(giveDiamond)
        .onDeny(tellWhy));

ClickGesture is PRIMARY, SECONDARY, SHIFT_PRIMARY, SHIFT_SECONDARY, MIDDLE, DOUBLE, DROP, CONTROL_DROP, NUMBER_KEY, UNKNOWN. ClickRule.missingOf(result) is every failed condition as one line. There is no sound(...) on the context: the stock sound effect does it.

ClickContext gives getViewer(), getGesture(), getKind(), getCursor() (ItemSpec, nullable), getElement()/getElementId(), getSlot(), getView(), newExecutionContext(), and the moves:

Call Does
open(screen) / open(screen, Boolean.class) pushes a screen; the future answers what it hands back, and is cancelled when nobody answers
back() / back(value) pops, handing value to the opener
replace(screen) swaps the top without growing the stack
ask(PromptSpec) / ask(question, parser, onAnswer) sets the screen aside, asks through the channel PromptRegistry picks, brings it back
close(), refresh(), allowMove() close, redraw, let the item move this click would make go through

Asking for a value no button can express

ctx.ask(PromptSpec.of("§eHow many?")
        .parse(text -> {
            if (!text.matches("[0-9]+")) throw new IllegalArgumentException("Not a number: '" + text + "'. Type digits only, like 250.");
            return Integer.valueOf(text);
        })
        .prefer(PromptKind.ANVIL)          // optional; null = the richest channel that serves the player
        .initialText("1")                  // anvil/page field; chat ignores it
        .icon(ItemSpec.of("minecraft:paper", 1))  // the anvil's input slot; default paper
        .timeout(Duration.ofSeconds(30))   // > 0, default 60 s
        .cancelWord("cancel"))             // chat; closing the anvil or the page cancels too
   .thenAccept(amount -> ...);             // cancelled (not failed) when no value came

PromptRegistry.get() keeps the question, whatever window it goes through: one per screen, the screen set aside before the channel opens and given back (without an onClose) before the future completes, the timeout on the screen's clock (ScreenScheduler.ticksOf), a refusal that never restarts the wait, a second question that ends the first CANCELLED and takes the screen over, and QUIT (with onQuit, nothing given back) when the screen is released mid-question - the player left, the chain was discarded, the server shuts down. The whole PromptResult (VALUE, CANCELLED, TIMEOUT, QUIT) comes from PromptRegistry.get().ask(view, spec); ctx.ask hands the value.

Which channel asks: prefer(kind) when it is registered and serves the player, otherwise the first registered in PromptKind order - PAGE, ANVIL, CHAT. None serves them: the question ends CANCELLED and the core logs how to register a channel. Bukkit registers ANVIL (BukkitAnvilPromptChannel) and CHAT (BukkitChatPromptChannel), Hytale PAGE (HyPagePrompt).

  • Anvil (ECNmsUtils' virtual anvil): the question is the title from 1.14 on and the icon's lore before that (1.7.10 and 1.12.2 have no title). Clicking the result answers; a refused answer keeps the anvil open with the parser's text as the result's name; closing it ends CANCELLED. Repair cost is 0 and no item reaches the player's inventory. Where NMSUtils.isSupported() is false, or the library's anvil classes do not link on that server, it does not serve the player and the question falls back to chat.
  • Chat: the window goes, the question is sent; a refused line never reaches public chat - the player reads why and the question again.
  • Page (Hytale): a field with OK and Cancel; a refusal shows under the field; escape cancels.

A channel is dumb: getKind(), isSupported(FPlayer), open(PromptSession), dismiss(PromptSession). It reports session.answer(typed) (a PromptReply: accepted() or refused(message)) and session.cancel() from any thread; a session that already ended ignores both. One channel per kind - PromptRegistry.get().register(owner, channel) refuses a second, naming both; the owner's shutdown takes it out.


State and reactivity

MutableState<T> (get, set, update) is a value a screen draws; dependsOn(state) on a button or a list redraws it when the value changes. Changes reach the renderer batched: two invalidations in one tick are one ScreenSurface.apply(List<ElementPatch>), carrying only the slots that changed.

Button.dependsOn(Condition...) makes a button follow game events: while the view is open, every EventSource the conditions declare (Condition.getNeeds()) is held needed through EventSourceUsage - so the platform's native listener for it is switched on - and an action of that source by the viewer redraws the button. Closing the view releases the holds. A condition with no source is asked again every sampleEvery(ticks) (20 by default).

screen.icon(l -> l.CLAIM).dependsOn(hasTheItem)        // a condition that needs item-pickup
        .visibleWhen(view -> view.test(hasTheItem));

visibleWhen(Predicate<ScreenView>[, ticks]) receives the view being drawn, so one screen shows an icon to one viewer and hides it from another (view.getViewer(), view.test(condition)). It is asked once a tick, every ticks, or only on a redraw with 0; the Supplier<Boolean> forms answer the same for every viewer.

ScreenNavigation.replace(current, next) (what ClickContext.replace runs) of a view set aside under another screen swaps it in place: next waits set aside where current was, inheriting what it owed its opener, and the screen on top keeps its window. A refused replacement drops the old screen from the chain and cancels its answer.

ScreenOpenEvent and ScreenCloseEvent (with its CloseReason) are posted on the core bus when someone listens.


Layouts: the file the admin owns

A layout class names icons by field; the first read seeds plugins/<Plugin>/guis/<LayoutClass>.yml, which the admin then restyles (item, name, lore, slots, per-language text) without touching code:

@ScreenLayout(title = "§8Kits", rows = 5)
public class KitsLayout extends Layout {

    @IconData(slot = {10, 11, 12, 13, 14, 15, 16})
    public Icon ENTRY = Icon.of("minecraft:chest");

    @IconData(slot = {36})
    public Icon PREVIOUS = DefaultIcons.previousPage();

    @IconData(slot = {44})
    public Icon NEXT = DefaultIcons.nextPage();

    @IconData(slot = {0, 8, 36, 44}, background = true)
    public Icon FRAME = Icon.of(DefaultIcons.pane(DefaultIcons.Color.GRAY));
}

Screen<KitsLayout> screen = Screen.of(KitsLayout.class);
screen.icon(l -> l.ENTRY).onClick(...);                     // checked by the compiler, not by a string

@ScreenLayout: title, rows (default 6), integrateToPAPI, locale. @IconData: slot, permission, background, order, group, locale. A DisplayItem is read as material:/type:/id:, amount:, name:, lore: (material: reads the vanilla minecraft: prefix in any case, MINECRAFT:STONE included); any other item-data line is kept raw on the Icon and applied by the renderer, which warns once per (layout, field) when it cannot. name:, lore:, the Locale blocks and Settings.title accept & colour codes: &a is read as §a (an & that starts no code, as in Tom & Jerry, stays text), a § written in the file is kept, and a seeded file writes its colours with &. Text written in code follows the same rule: Screen.title (and so ConfirmRequest.of, RankingRequest.title, Menu.Builder.title), Icon.displayName and Icon.lore read &a and &#RRGGBB as colour; a value put in later ({0}, a replacer, a placeholder) is never coloured. An icon's per-language text goes in addLocale(lang, name, lore...); a later displayName/lore replaces that block, so DefaultIcons.info().displayName("x") draws "x". A layout field that names its text both in @IconData(locale = ...) and in .displayName() is refused as "the same icon in two places".

Text resolves in one fixed order, whatever order the screen was declared in: the viewer's language block, the open's {0}..{n}, the icon's scopes (icon.addScope(replacer, object)), the screen's replacers, its subject, then PlaceholderAPI when the layout asks for it.

/ecore screen reload re-reads the files, /ecore screen diff shows what the files change against the code, /ecore screen which <key> [plugin] names the override layer that answers a key (permission evernifecore.command.screen).

DefaultIcons carries the shared ones (previousPage(), nextPage(), confirm(), deny(), wool(Color), pane(Color)), with mod items first and vanilla as the last candidate.


Renderers

public interface ScreenRenderer {
    String getId();
    int getPriority();
    Set<RendererCapability> getCapabilities();
    boolean supports(FPlayer viewer, Screen<?> screen);
    ScreenSurface open(ScreenView view);
    ScreenScheduler getScheduler();
}

Screens.registerRenderer(owner, renderer) puts it in the renderer TypeRegistry (Screens.registry()); the owner's shutdown takes it out. On open the core picks the highest priority renderer that supports the viewer and the screen and covers the screen's capabilities:

RendererCapability Needed when
STORAGE_SLOTS the screen declares an editable area (storage(...))
TEXTURES a custom texture is drawn
TITLE_CHANGE the title changes while the window is open

No renderer covering the screen refuses the open naming the capability and the renderer - or opens onMissingCapability(ScreenFallback.open(otherScreen | otherKey)).

ScreenSurface.apply(List<ElementPatch>) is the single write point. An ElementPatch carries the element id, its ElementKind, the slot, the resolved RenderedIcon, the progress ratio and whether it is enabled (and why not) - enough for a renderer that draws by element. A grid renderer extends GridSurface, which turns patches into writeSlot calls; the diff lives in the ScreenView, so both kinds only ever receive what changed. ScreenSurface.released(CloseReason) is where a window gives back what it held, before onClose. ScreenSurface.suspended() (a no-op by default, renderer thread) says the view was set aside - a prompt asks, or a screen opened on top: the window goes, the view stays and comes back on a new surface, and nothing reports this window closed. The chest closes its inventory there a tick later, only if the player still has it open (a screen or an anvil opened on top replaces the window by itself); a click on it in that tick is cancelled.

A renderer reports a window that went away with Screens.windowClosed(view, reason) / Screens.viewerGone(uuid, reason); ScreenView.click(slot, gesture, kind, cursor) and click(elementId, gesture) route a click (both answer whether the item move may go through, not whether something handled the click).


Screens: keys, overrides, default screens

A ScreenKey (evernifecore:confirm, ScreenKey.of(owner, name), ScreenKey.parse) names a screen others may replace. Screens.open(player, key, ScreenRequest) resolves it through four layers, most specific first:

  1. the caller's replacement - ScreenRequest.of(owner).withScreen(otherKey);
  2. the content owner's override - ScreenScope.ownedBy(plugin), matched on the request's owner;
  3. the server's override - ScreenScope.server(), one plugin per key: a second one throws naming both;
  4. the core's default.
Cancellable undo = Screens.override(myPlugin, ScreenKey.CONFIRM, ScreenScope.server(),
        ctx -> MyConfirmScreen.of(ctx.getRequest(ConfirmRequest.class)));

Screens.open(player, ScreenKey.RANKING, ScreenRequest.of(myPlugin)
        .payload(RankingRequest.of(killsRanking).title("§8Top killers")));

The factory is a Function<ViewContext, Screen<?>>: ViewContext gives the viewer, key, owner, args, locale, an ExecutionContext and getRequest(Class<R>) - the payload, or a refusal that teaches ScreenRequest.of(plugin).payload(...). Cancellable.cancel() hands the key back to the layer below; a plugin's shutdown removes its overrides and closes its views (Screens.closeOwnedBy). Screens.closeAll() and Screens.closeOwnedBy(owner) run from any thread: each screen closes (and each one set aside is released) on its renderer's thread, inline when the caller is already there, and the call waits up to 5 s, naming in a SEVERE line the viewers whose screens did not close.

The core ships four keys, each a working example with its layout seeded under guis/:

Key Screen Request
ScreenKey.CONFIRM ConfirmScreen<L extends ConfirmLayout> ConfirmRequest.of(question[, gate, effects...]), .display(item), .dangerous() (shift-click to confirm), .owner(plugin); {cost} in the lore. Screens.confirm(player, request) answers CompletableFuture<Boolean>, cancelled when the player walks away
ScreenKey.DELIVERIES DeliveriesScreen none: the viewer's pending deliveries, claimed one by one or all at once; the list fills a moment after the window opens
ScreenKey.RANKING RankingScreen RankingRequest.of(rankedList).title(...) over a RankedList: the viewer's row marked, a bar to the next position
ScreenKey.DIALOGUE DialogueScreen the DialogueSession as payload, opened by the session itself: one node of a dialogue, a Choice per option

ConfirmScreen.of(request) answers a ConfirmScreen<ConfirmLayout>. A plugin with a confirmation of its own extends it (protected constructor) over a layout that extends ConfirmLayout, and keeps the shift-click of a dangerous request, the paid gate and {cost}:

@ScreenLayout(title = "§8Claim it?", rows = 3)
public class ClaimLayout extends ConfirmScreen.ConfirmLayout {
    @IconData(slot = {4})
    public Icon REWARD = Icon.of(ItemSpec.of("minecraft:chest", 1));
}

public class ClaimScreen extends ConfirmScreen<ClaimLayout> {
    public ClaimScreen(ConfirmRequest request) {
        super(Layouts.of(ClaimLayout.class), request);   // CONFIRM and DENY already wired
        icon(l -> l.REWARD).id("reward");
    }
}

The stock effect open-screen (screen, args) opens a key from logic; a context with no player is audited and opens nothing.


Testing: ScreenWorld and RendererConformance

evernifecore-common-tests drives screens without a server (Testing Toolkit):

try (ScreenWorld world = ScreenWorld.open(tempDir)) {
    ScreenView view = world.open(screen, world.viewer("Steve"));
    world.click(view, "buy", ClickGesture.PRIMARY);
    world.advanceTicks(1);
    RenderedIcon shown = world.getRenderer().surfaceOf(view).getSlot(13);
    view.close();
    world.advanceTicks(2);
}

The RecordingRenderer keeps every patch (RecordingSurface.getApplies(), getSlot, getLastPatch); world.recorded(ScreenOpenEvent.class) reads the bus; close() fails when a view is left open or a source is still held. world.click(...) answers whether the item move the gesture would make may go through, never whether a handler ran - assert the click's effect. RendererConformance.check(renderer, world) answers the list of failed scenarios, and every renderer passes it, the chest included: open-registers-view, open-posts-event, click-routes-by-id-and-gesture, patch-batches-per-tick, patch-touches-only-dirty-elements, close-runs-onclose-once, close-posts-event-with-reason, prompt-sets-aside-and-restores, nav-push-pop-hands-value, capability-refusal-teaches, title-change-when-declared, and one per element kind - label-draws-its-text, choice-disabled-carries-reason, progress-reports-ratio, input-asks-through-a-prompt, list-pages-with-arrows. check(renderer, world, Supplier<FPlayer> viewers) takes the player each scenario opens for (each call answers one with no screen open), for a renderer whose supports needs a per-player handshake.

To read what the Bukkit chest itself drew (an icon's name and lore), evernifecore-minecraft-tests ships BukkitScreenWorld.install(dir) (testing.minecraft.screen): the real ChestScreenRenderer over a described server (Testing Toolkit).


The chest renderer

On Bukkit, ChestScreenRenderer (evernifecore:chest, priority 0, in minecraft.screen) draws a screen as a vanilla container. It is the fallback every Bukkit player gets, and the only renderer with STORAGE_SLOTS (plus TITLE_CHANGE). The window follows the screen's grid: Screen.of(rows) is a chest of 1-6 rows, ChestKind.HOPPER.newScreen() (or Screen.of(ChestKind.X.getGrid())) a hopper, dispenser, brewing stand or workbench.

  • An icon id is a modern one (minecraft:red_wool); before 1.13 coloured wool and glass map to their data value. An icon none of whose candidates the server has is drawn as a barrier (stone before 1.8), warned once per element.
  • A title longer than 32 characters is cut up to 1.8, where the protocol cannot hold it.
  • FCItemBuilder.asIcon() turns a built stack into a screen.Icon.
  • ChestItems.specOf(stack) carries the stack's own name and lore, so a player's item is drawn with the name it has.
  • Questions go through the anvil (BukkitAnvilPromptChannel), or chat where the anvil does not serve the player.
  • A disabled element (a Choice whose gate fails) shows its name grey, unless it has a colour of its own, and its reason as the last lore lines in red, one failure per line.
  • A spec's data value (ItemSpec.withData) is drawn before 1.13; from 1.13 the colour is in the id.
  • An icon re-read from guis/<Layout>.yml keeps the fallbacks its field declared (the file holds only the primary item), so CONFIRM/DENY still fall back to wool on a server without the mod items (Icon.setFallbacks, Icon.getStateFallbacks(name)).

Editable areas - storage(...)

This is the one place where a click is not cancelled, so nothing moves until the declaration names the gestures that may:

screen.storage(l -> l.AREA)                     // or storage(SlotSet)
        .backedBy(backpack)                      // an ItemStore or a GenericInventory
        .policy(ClickPolicy.EDIT_ALL)
        .denyPlace(ChestItems.stacks(stack -> Blacklist.contains(stack)))
        .onChange(change -> owner.markDirty());
Call Meaning
backedBy(Object) the store filled from when the area opens and written back after every change: an ItemStore or a GenericInventory
policy(ClickPolicy) which gestures go through; whatever is not named is cancelled
denyPlace(Predicate<ItemSpec>) / allowPlace(...) which items may enter; filters add up. ChestItems.stacks(Predicate<ItemStack>) writes one over the Bukkit stack
onChange(Consumer<StorageChange>) after every accepted change, on the next tick, and once more when the screen goes away for any reason (StorageChange.isLast()); contents as ItemSpecs carrying the stack

ClickPolicy.EDIT_ALL is everything a player needs to move an item, minus the two that would duplicate one; ClickPolicy.builder() is the granular form. A double click (COLLECT_TO_CURSOR) is refused on the whole screen; a shift-move is TAKE leaving the area and PLACE arriving from the viewer's inventory. onChange never saves on its own.

The invariant: over any sequence of clicks the sum of the area, the viewer's inventory and their cursor does not change. Nothing paints over an editable slot, every gesture the renderer takes apart adds exactly what it takes, and the store is only written from what the container holds. Not covered: two screens open on the same store at once - the last to close wins, and the log says so.

ItemStore and StoredInventory

GenericInventory StoredInventory
Capacity unbounded declared, and enforced
Maximum per slot whatever the item says per slot, overridable
Asked before a change no onPreUpdate, which may refuse it
Told after a change no onPostUpdate
Stored as a bare slot map a versioned envelope
StoredInventory backpack = new StoredInventory(27);
backpack.setMaxStackSize(0, 1);
backpack.onPreUpdate(event -> event.setCancelled(locked));
backpack.onPostUpdate(event -> owner.markDirty());

setItem(UpdateCause, int, ItemStack) asks onPreUpdate, writes when it agrees and tells onPostUpdate; setItemSilently does neither; reads answer copies. UpdateCause is PLAYER (a gesture in an open screen) or PLUGIN. A declared maximum is what a shift-click fills up to; an item already in the slot is never cut down.

It persists as a versioned envelope (version, size, items, maxStackSizes); a file with no version is version 1, the old bare slot map. StoredInventorySchema.registerMigration(n, stored -> ...) chains upgrades, and a missing, unknown or newer version is refused rather than read as an empty chest. A decoder rebuilds one through StoredInventory.restoring(size) ... build(), the one door that skips the thread rule while the inventory is nobody's yet.

Moving an item happens on the server main thread: setItem, setItemSilently and setCapacity throw from any other, naming the thread. Describing an inventory (setMaxStackSize, the handlers) and reading it are allowed anywhere. See Scheduler and Threading.


The page renderer (Hytale)

On Hytale, HyPageRenderer (evernifecore:page, priority 0, in hytale.screen) draws a screen as a server page, an InteractiveCustomUIPage: a frame sized to the grid, the title, one cell per slot. Details and threads are in Screens on Hytale; what differs from the chest:

Chest (evernifecore:chest) Page (evernifecore:page)
Capabilities STORAGE_SLOTS, TITLE_CHANGE TITLE_CHANGE
Icon the item id, or a barrier when the server has none the hytale: asset id (or a Hytale fallback), else Rock_Stone
Name and lore the item's own name and lore the cell's tooltip
Gestures every Bukkit click PRIMARY, SECONDARY, DOUBLE
Close reasons PLAYER_CLOSED (the inventory close comes first) PLAYER_CLOSED (escape), REQUESTED (close(), another screen), DISCONNECTED (quit)
Prompt channels ANVIL (BukkitAnvilPromptChannel), CHAT (BukkitChatPromptChannel) PAGE (HyPagePrompt)
Thread the main thread its own EverNifeCore Screens thread; the page on the viewer's world thread

Renderers outside the core: the mod renderer

Nothing above is chest-specific: a plugin can ship its own ScreenRenderer, and every screen of every plugin is drawn by it for the players it serves, with no change to those plugins. The reference case is a renderer that draws screens through a client mod over a plugin-message channel. How such a renderer fits the SPI:

  • Priority above the chest (the chest and the page are 0; the mod renderer uses 100), and supports(viewer, screen) answers per player: true only after that player's client said a compatible hello on the channel. A player without the mod - or with a mod of another protocol version - is never offered it, so the chest stays the fallback and nothing breaks.
  • open(view) sends the window, apply(patches) sends the changes. The core calls render() and commits right after open, so the first patch carries every slot: the renderer serializes, it never decides. Text is already resolved for the viewer, icons arrive as RenderedIcon (item, fallbacks, data lines).
  • The client is untrusted. A click from the mod is revalidated on the server before it reaches ScreenView.click(slot, gesture, kind, cursor): the view is open and belongs to that player, the element is on that slot and enabled, the sequence number grows. From there the screen's ClickRule, debounce and gates run in the core exactly as for a chest click - a modified client can only send what a chest could.
  • Closing has two directions. close(reason) (the core asked) tells the client and reports Screens.windowClosed; released(reason) (the player left, changed world) only tells the client; a close the client reports comes back as Screens.windowClosed(view, PLAYER_CLOSED).
  • Capabilities are honest. It declares TITLE_CHANGE and TEXTURES; not STORAGE_SLOTS (no real items move through a mod window, so a screen with storage(...) falls to the chest). An InputField asks through PromptRegistry on every renderer.
  • Textures ride on the icon fallbacks. An ItemSpec whose id is not a game item (Icon.of(ItemSpec.of("srv:coins/gold", 1), ItemSpec.of("minecraft:gold_ingot", 1))) is drawn as a picture by a renderer that knows it, and every other renderer draws the first fallback it has - the chest shows the gold ingot.
  • Test it with the same suite. RendererConformance.check(renderer, world, viewers) runs against the mod renderer with its channel replaced by a queue; viewers answers players that already made the handshake.

What this framework deliberately does not build

  • Sign input. Free text is the anvil or chat (PromptRegistry); another channel plugs in under a PromptKind.
  • Item identity by UUID or NBT as a default. Only inside an editable area.
  • A layout grammar other than slots. Slots, NamedRegion and the yml are the only ways to place.

See also

  • Menus - a screen with offers, registered by name: prices, buy and sell, the offer button
  • Logic - Gate, Condition, Effect, the pieces a ClickRule is made of
  • Items and NBT - ItemSpec and the item-data lines a DisplayItem is written in
  • Localization - @FCLocale, LocaleMessage and per-player language
  • Placeholders - the replacer engine a screen's text is resolved through
  • Testing Toolkit - ScreenWorld, RendererConformance
  • In-Server Conformance - the screen suite, on every Bukkit flavor
  • Gotchas and Pitfalls - what a screen close can lose, and why

Clone this wiki locally