Salt's Inventory Update

Desktop Window API v2

A practical guide for modding developers building custom desktop windows across Salt's supported Minecraft versions and loaders.

Targets Fabric, Forge, NeoForge Status API v2 draft

Overview

The Desktop Window API lets another mod register a custom Salt desktop window for one of its container menu types. Salt keeps ownership of the desktop engine: window placement, moving, locking, pinning, ghost previews, resize handles, carried stack synchronization, hotbar interaction, shift-click routing, and multi-window server sessions. Mods only describe the inside of a window and any server-side behavior that their menu needs.

Design goal: Let mod authors build accurate UI windows without reimplementing Salt's desktop session system or Minecraft's slot click protocol.

Client responsibilities

  • Choose title and dimensions.
  • Render custom widgets, sprites, text, slots, and virtual entries.
  • Return slot hits for real menu slots.
  • Handle custom clicks, text input, scrollbars, tabs, and tooltips.
  • Send custom session-scoped payloads when vanilla slot routing is not enough.

Server responsibilities

  • Opt the menu into Salt desktop capture.
  • Keep any custom session state ticking.
  • Validate and handle custom payloads.
  • Broadcast menu changes and custom snapshots.
  • Clean up when sessions close or ghost previews expire.

Version Matrix

The examples on this page use the 26.x API spelling. The desktop API concepts are shared across every supported version, but a few Minecraft type names and typed payload helpers differ.

Version Loaders Resource id type Slot click type Input event types Typed payload helpers
26.1.2 Fabric, NeoForge Identifier ContainerInput net.minecraft.client.input.* Yes
26.2 Fabric, NeoForge Identifier ContainerInput net.minecraft.client.input.* Yes
1.21.11 Fabric, NeoForge Identifier ClickType net.minecraft.client.input.* Yes
1.21.1 Fabric, NeoForge ResourceLocation ClickType com.salts_inventory_update.client.input.* Yes
1.20.1 Fabric, Forge ResourceLocation ClickType com.salts_inventory_update.client.input.* Raw byte payloads only
Loader note: Fabric and NeoForge do not have separate Salt desktop API surfaces. Forge and NeoForge builds compile the same per-version API sources as Fabric, then add a loader shim for entrypoints, events, commands, key bindings, and networking.
  • On 1.20.1 and 1.21.1, replace Identifier with ResourceLocation.
  • On 1.20.1, 1.21.1, and 1.21.11, replace ContainerInput with ClickType.
  • On 1.20.1 and 1.21.1, import input event types from com.salts_inventory_update.client.input.
  • On 1.20.1, use raw byte[] payloads instead of typed codec helpers.
  • On 1.20.1 and 1.21.1, createRecipeBook returns raw RecipeBookComponent.

Mental Model

A Salt desktop window has three layers. The outer desktop layer is owned by Salt. The menu layer is the normal Minecraft AbstractContainerMenu. The mod layer is the custom UI definition that renders and routes input for the menu.

Salt Desktop Engine window frame, focus, carried stack, lock, pin, ghost, placement
Server Session live menu, slot sync, data sync, custom payloads
Mod Window Definition rendering, custom widgets, virtual item grid, text input

Do not create a new screen for your container. Register a definition. Salt will call your definition when the matching menu is opened inside the desktop.

Registration

Client and server registration are separate. This is intentional: a client-only definition can describe how a menu should look, while server registration decides whether that menu is safe for Salt to capture as a live desktop session.

Client registration

SaltsInventoryDesktopApi.registerClientWindow(MyMenus.MY_MENU, new MyWindowDefinition());

Replace an existing definition

SaltsInventoryDesktopApi.replaceClientWindow(MyMenus.MY_MENU, new MyBetterWindowDefinition());

Predicate registration

Use predicates when one direct MenuType is not enough. A predicate can inspect the lookup context and decide whether to create a definition.

SaltsInventoryDesktopApi.registerClientWindowPredicate(
    Identifier.fromNamespaceAndPath("my_mod", "dynamic_storage"),
    100,
    context -> context.sourceKey().startsWith("block:"),
    context -> new MyDynamicStorageWindow()
);

Server opt-in

SaltsInventoryDesktopApi.registerServerWindow(MyMenus.MY_MENU, new MyServerWindowHandler());
Important fallback rule: Unknown modded menus are not captured. If a modded menu has no Salt server registration, Salt lets the original mod screen open normally.

Client Definition Lifecycle

Implement DesktopWindowDefinition<T, S>. T is your menu class. S is your per-window client state class.

Hook When Salt calls it Common use
createState(setup) Once when the window object is created. Create selected tab, search state, scroll rows, animation counters.
title(context) When drawing title bar text. Override a verbose vanilla title with a cleaner one.
defaultSize(setup) Before initial placement. Choose a fixed functional size or default grid size.
minSize(context) During resize clamp. Prevent crushing buttons, search bars, slots, or labels.
resizePolicy(context) When deciding if resize is allowed. Return fixed or storage-grid style.
snapSize(context) When a resize drag ends. Snap to whole slot columns and rows.
opened(context) After the window is added to the desktop. Play local open animation, initialize UI caches.
closed(context) Before the window is removed for real. Release client-only resources.
moved(context) After a move drag commits. Re-anchor attached popups or local overlays.
resized(context) After a resize commits and snaps. Recalculate cached grid dimensions.
focusChanged(context, focused) Whenever Salt focus changes. Pause typing, close dropdowns, change highlight state.
ghosted(context) When a ghost-pinned live window demotes to preview. Close controls, stop text editing.
unghosted(context) When a preview promotes back to interactive. Resume animations or restore UI state.
tick(context) Every client tick while window exists. Animate, poll reflected values, update local state.
render(context) Every frame. Draw the content area.
slotAt(context, mouseX, mouseY) When Salt needs to route a slot click. Return real menu slot hitboxes.
mouseClicked, mouseReleased, mouseDragged For custom non-slot interactions. Tabs, buttons, virtual item entries, scroll thumbs.
mouseScrolled Before Salt falls back to hotbar scrolling. Consume wheel input for hovered scrollable UI.
keyPressed, charTyped For keyboard input while desktop is active. Text boxes, hotkeys, search fields.
wantsTextInput(context) During movement/key routing. Suppress WASD and inventory hotkeys while typing.
appendTooltip(context, mouseX, mouseY) When Salt asks the top window for tooltips. Custom button descriptions and virtual item tooltips.
customPayload(context, channel, data) When server sends a session-scoped payload. Apply snapshots, progress updates, custom state.
loadLocalState, saveLocalState When Salt loads/saves window geometry state. Persist selected tab, sort mode, search mode, collapsed panels.

Context Objects

Contexts are intentionally safer than exposing Salt's internal window classes. They give mods what they need without tying them to the internal engine.

DesktopWindowContext

  • minecraft(), menu()
  • originalTitle()
  • sessionId(), sourceKey()
  • state()
  • windowX/Y/Width/Height()
  • contentX/Y/Width/Height()
  • focused(), minimized(), ghosted()
  • carriedStack()
  • fontWidth(), trimToWidth()

DesktopRenderContext

  • fill, text, scaledText
  • sprite, texture
  • windowNineSlice, onePixelNineSlice
  • item, virtualItem
  • slot, renderSlot, texturelessSlot
  • slotBackground, slotHighlight
  • entityPreview
  • tooltip

DesktopInputContext

  • shiftDown(), ctrlDown(), altDown()
  • mouseButtonDown(button)
  • sendMenuButton(buttonId)
  • sendRename(name)
  • clickSlot(...), quickMoveSlot(...)
  • toggleRecipeBook()
  • setRecipeBookSearch(search)
  • sendPayload(...)
Version note: clickSlot takes ContainerInput on 26.1.2 and 26.2. It takes ClickType on 1.20.1, 1.21.1, and 1.21.11. Typed sendPayload(channel, payload, codec) is available on every supported version except 1.20.1.

Rendering

Your render hook draws inside Salt's framed window. Coordinates are absolute screen coordinates, not relative coordinates. Start from context.contentX() and context.contentY().

@Override
public void render(DesktopRenderContext<MyMenu, State> context) {
    int x = context.contentX();
    int y = context.contentY();

    context.text("Mode", x, y, 0xFF202020, false);
    context.sprite(MY_ICON, x + 48, y - 2, 16, 16);
    context.slotBackground(x, y + 18);
    context.renderSlot(0, x, y + 18);
}
Tip: Avoid rendering a duplicate player inventory or hotbar inside your container. Salt already provides independent inventory and hotbar interaction.

Real Slot Routing

For normal item slots, render the slot and return a DesktopSlotHit from slotAt. Salt will handle pickup, place, split stacks, quick craft drag, double click collection, shift-click, carried stack sync, and server validation.

@Override
public DesktopSlotHit slotAt(DesktopSlotContext<MyMenu, State> context, double mouseX, double mouseY) {
    int x = context.contentX();
    int y = context.contentY();
    return context.hitSlot(5, x + 32, y + 16, mouseX, mouseY);
}
Common mistake: Use menu slot ids, not visual slot order. slotAt must return the index into menu.slots.

Input

Salt asks custom handlers first for non-slot interactions. Return true only when your UI consumed the input. Returning false allows Salt to continue with normal slot handling, hotbar scrolling, or world pass-through behavior.

@Override
public boolean mouseClicked(DesktopInputContext<MyMenu, State> context, MouseButtonEvent event, boolean doubleClick) {
    if (event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT && MY_BUTTON.contains(event.x(), event.y())) {
        context.sendMenuButton(3);
        return true;
    }
    return false;
}

Text input should also implement wantsTextInput. That is the signal Salt uses to suppress movement keys while the player is typing.

Widget Helpers

The widget helpers are optional. They exist to remove repetitive UI code from mod windows without forcing a complete framework.

Helper Use
DesktopTextBoxState Stores text and focus for a simple search/text box.
DesktopWidgets.renderTextBox Draws Salt's dynamic 3-slice search bar texture.
clickTextBox, keyPressedTextBox, charTypedTextBox Basic focus, backspace, delete, escape, enter, and text entry.
renderIconButton, renderTextButton Small generic buttons for config style controls.
renderScrollbar Salt creative-style scrollbar background and thumb.
renderDropdown Simple popup menu with labels on the left and state buttons on the right.
renderVirtualItemGrid, virtualItemIndexAt Terminal-style item grids that are not backed by normal slots.
public static final class State {
    final DesktopTextBoxState search = new DesktopTextBoxState();
}

@Override
public void render(DesktopRenderContext<MyMenu, State> context) {
    DesktopWidgets.renderTextBox(context, context.state().search, context.contentX(), context.contentY(), 120);
}

@Override
public boolean wantsTextInput(DesktopWindowContext<MyMenu, State> context) {
    return DesktopWidgets.wantsTextInput(context.state().search);
}

Server Support

The server handler is the opt-in that lets Salt capture a menu as a desktop session. If your menu has no server handler and is not a vanilla Salt-supported menu, Salt will not capture it.

public final class MyServerWindow implements DesktopServerWindowHandler<MyMenu, MyServerWindow.State> {
    public static final class State {
        long lastSnapshotHash = Long.MIN_VALUE;
    }

    @Override
    public State createState(DesktopServerSessionContext<MyMenu, State> context) {
        return new State();
    }

    @Override
    public void tick(DesktopServerSessionContext<MyMenu, State> context) {
        context.menu().broadcastChanges();
    }

    @Override
    public void closed(DesktopServerSessionContext<MyMenu, State> context) {
        // The live session is ending here.
    }
}

Hidden ghost-pinned sessions still tick while valid. Use context.visible() and context.ghostPinned() if your server logic needs to distinguish interactive windows from ghost previews.

Custom Payloads

Payloads are session-scoped. This matters for multi-window support: two windows of the same menu type can be open at the same time, and payloads must go to the correct session.

Version note: Payload channels use Identifier on 26.1.2, 26.2, and 1.21.11. Use ResourceLocation on 1.21.1 and 1.20.1.

Raw byte payload

SaltsInventoryDesktopApi.registerServerPayload(MyMenus.MY_MENU, MY_CHANNEL, context -> {
    byte[] data = context.data();
    context.broadcastChanges();
});

Typed payload

Typed payload helpers are available on 1.21.1, 1.21.11, 26.1.2, and 26.2. On 1.20.1, register a raw byte payload and encode or decode the bytes yourself.

public record ModePayload(int mode) {
    public static final StreamCodec<RegistryFriendlyByteBuf, ModePayload> CODEC =
        StreamCodec.composite(ByteBufCodecs.INT, ModePayload::mode, ModePayload::new);
}

SaltsInventoryDesktopApi.registerServerPayload(
    MyMenus.MY_MENU,
    MY_CHANNEL,
    ModePayload.CODEC,
    (context, payload) -> {
        context.menu().setMode(payload.mode());
        context.broadcastChanges();
    }
);

Client send

context.sendPayload(MY_CHANNEL, new ModePayload(2), ModePayload.CODEC);

On 1.20.1, send encoded bytes instead:

context.sendPayload(MY_CHANNEL, encodedBytes);
Limit: Keep custom payloads small. The API is designed around a 32 KiB cap.

Resizing And Snap Sizes

Salt supports fixed windows and storage-grid style resizable windows. A resizable custom window should define a minimum size and a snap size so it does not keep awkward leftover space.

@Override
public DesktopResizePolicy resizePolicy(DesktopWindowContext<MyMenu, State> context) {
    return DesktopResizePolicy.STORAGE_GRID;
}

@Override
public DesktopWindowSize minSize(DesktopWindowContext<MyMenu, State> context) {
    return DesktopWindowSize.of(8 + 3 * 18 + 8, 16 + 8 + 2 * 18 + 8);
}

@Override
public DesktopWindowSize snapSize(DesktopWindowContext<MyMenu, State> context) {
    int columns = Math.max(3, (context.contentWidth() - 14) / 18);
    int rows = Math.max(2, context.contentHeight() / 18);
    return DesktopWindowSize.of(8 + columns * 18 + 14 + 8, 16 + 8 + rows * 18 + 8);
}

Salt still respects the user's global config. If resizing is disabled globally or the window is locked, resize grips are disabled even when the definition returns a resizable policy.

State And Persistence

There are three kinds of state to think about.

State type Owner Examples
Menu state Minecraft server menu Slots, progress data, selected trade, furnace burn time.
Client window state Your S object Scroll row, selected tab, search focus, animation frame.
Persistent local state Salt window state store Saved selected tab, search mode, collapsed panels.
@Override
public void saveLocalState(DesktopWindowContext<MyMenu, State> context, CompoundTag tag) {
    tag.putInt("tab", context.state().selectedTab);
}

@Override
public void loadLocalState(DesktopWindowContext<MyMenu, State> context, CompoundTag tag) {
    context.state().selectedTab = tag.getIntOr("tab", 0);
}

Lock, Pin, And Ghost Pin

Mod windows inherit Salt's window controls. Definitions do not need to implement lock, pin, movement, close, focus, minimize, or ghost preview behavior.

Locked

Window cannot be moved or resized. Slot and custom UI interactions still work.

Pinned

Window reopens at its saved position and size instead of using automatic placement.

Ghost pinned

Closing demotes the live session into a translucent preview while valid and nearby.

Ghost previews should be treated as read-mostly UI. Salt blocks normal item movement while ghosted. Your custom input handlers should also avoid mutating server state when context.ghosted() is true.

Recipe Book

If your menu supports Minecraft's recipe book, return a component from createRecipeBook. Salt hosts it as an attached object rather than a separate desktop window.

@Override
public RecipeBookComponent<?> createRecipeBook(DesktopWindowContext<MyMenu, State> context) {
    return MyClientReflect.createRecipeBook(context.menu());
}

On 1.20.1 and 1.21.1, return raw RecipeBookComponent instead of RecipeBookComponent<?>.

Use context.toggleRecipeBook() from a custom button, and context.setRecipeBookSearch(search) when your UI wants to sync search text into the book.

Virtual Item Entries

Some terminal menus display stored items that are not actual slots. These should not return DesktopSlotHit. Render them as virtual items and send custom payloads for actions.

List<DesktopVirtualItem> visible = entries.stream()
    .map(entry -> new DesktopVirtualItem(entry.stack(), entry.count()))
    .toList();

DesktopWidgets.renderVirtualItemGrid(context, visible, firstIndex, gridX, gridY, columns, rows);

int index = DesktopWidgets.virtualItemIndexAt(
    event.x(), event.y(), gridX, gridY, columns, rows, firstIndex, visible.size()
);
if (index >= 0) {
    context.sendPayload(PULL_CHANNEL, new PullPayload(index), PullPayload.CODEC);
    return true;
}

On 1.20.1, encode the pull request yourself and call context.sendPayload(PULL_CHANNEL, bytes).

Do not fake slots: A virtual item is not a Slot. If there is no real menu slot, use a payload.

Fallbacks And Compatibility

Salt's fallback behavior is conservative. If another mod is installed and has a menu Salt does not know how to manage, the original screen should open normally. This avoids broken slot-only windows for complex mod UIs.

  • Register client definitions only when the other mod is present.
  • Lookup menu types by registry id instead of hard class references in always-loaded code.
  • Register server support only when the menu is safe for Salt's multi-window session model.
  • Use reflection or guarded compat classes if the dependency is optional.
  • Log clearly when a compat target is missing or has changed.

Complete Minimal Example

public final class ExampleCompat {
    public static void clientInit() {
        MenuType<ExampleMenu> menu = ExampleMenus.EXAMPLE;
        SaltsInventoryDesktopApi.registerClientWindow(menu, new ExampleWindow());
    }

    public static void serverInit() {
        MenuType<ExampleMenu> menu = ExampleMenus.EXAMPLE;
        SaltsInventoryDesktopApi.registerServerWindow(menu, new ExampleServerWindow());
    }

    private static final class ExampleWindow implements DesktopWindowDefinition<ExampleMenu, State> {
        private static final class State {
            final DesktopTextBoxState search = new DesktopTextBoxState();
        }

        @Override
        public State createState(DesktopWindowSetupContext<ExampleMenu> context) {
            return new State();
        }

        @Override
        public Component title(DesktopWindowContext<ExampleMenu, State> context) {
            return Component.literal("Example");
        }

        @Override
        public DesktopWindowSize defaultSize(DesktopWindowSetupContext<ExampleMenu> context) {
            return DesktopWindowSize.of(190, 112);
        }

        @Override
        public void render(DesktopRenderContext<ExampleMenu, State> context) {
            int x = context.contentX();
            int y = context.contentY();
            DesktopWidgets.renderTextBox(context, context.state().search, x, y, 120);
            context.renderSlot(0, x, y + 20);
            context.renderSlot(1, x + 18, y + 20);
        }

        @Override
        public DesktopSlotHit slotAt(DesktopSlotContext<ExampleMenu, State> context, double mouseX, double mouseY) {
            int x = context.contentX();
            int y = context.contentY() + 20;
            DesktopSlotHit first = context.hitSlot(0, x, y, mouseX, mouseY);
            return first != null ? first : context.hitSlot(1, x + 18, y, mouseX, mouseY);
        }

        @Override
        public boolean mouseClicked(DesktopInputContext<ExampleMenu, State> context, MouseButtonEvent event, boolean doubleClick) {
            return DesktopWidgets.clickTextBox(context.state().search, event, context.contentX(), context.contentY(), 120);
        }

        @Override
        public boolean keyPressed(DesktopInputContext<ExampleMenu, State> context, KeyEvent event) {
            return DesktopWidgets.keyPressedTextBox(context.state().search, event);
        }

        @Override
        public boolean charTyped(DesktopInputContext<ExampleMenu, State> context, CharacterEvent event) {
            return DesktopWidgets.charTypedTextBox(context.state().search, event);
        }

        @Override
        public boolean wantsTextInput(DesktopWindowContext<ExampleMenu, State> context) {
            return DesktopWidgets.wantsTextInput(context.state().search);
        }
    }

    private static final class ExampleServerWindow implements DesktopServerWindowHandler<ExampleMenu, Object> {
    }
}

Proof Case: Tom's Simple Storage

Tom's Simple Storage is the current benchmark compat target for the API. Its terminal windows use virtual item entries, search settings, custom controls, custom payloads, server-side snapshots, and normal Salt slot behavior for crafting slots.

  • Client definitions are registered with replaceClientWindow.
  • Server support is registered with registerServerWindow.
  • Tom snapshots are sent from DesktopServerWindowHandler.tick.
  • Terminal entries use custom payloads because they are not real slots.
  • Crafting grid and output slots fall through to Salt's normal slot click handling.

Mod Checklist

  1. Guard optional compat with a mod-loaded check.
  2. Find the target MenuType by registry id.
  3. Register a client window definition.
  4. Register a server window handler if the menu should be desktop-managed.
  5. Render real slots with renderSlot and return DesktopSlotHit.
  6. Render virtual entries separately and send payloads for them.
  7. Use wantsTextInput for focused text boxes.
  8. Make resize minimums protect all important UI elements.
  9. Use snapSize if resizing should align to slot grids.
  10. Test with multiple windows open, ghost pin, lock, Alt pass-through, shift-click, and carried stacks.