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.
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 |
- On
1.20.1and1.21.1, replaceIdentifierwithResourceLocation. - On
1.20.1,1.21.1, and1.21.11, replaceContainerInputwithClickType. - On
1.20.1and1.21.1, import input event types fromcom.salts_inventory_update.client.input. - On
1.20.1, use rawbyte[]payloads instead of typed codec helpers. - On
1.20.1and1.21.1,createRecipeBookreturns rawRecipeBookComponent.
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.
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());
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,scaledTextsprite,texturewindowNineSlice,onePixelNineSliceitem,virtualItemslot,renderSlot,texturelessSlotslotBackground,slotHighlightentityPreviewtooltip
DesktopInputContext
shiftDown(),ctrlDown(),altDown()mouseButtonDown(button)sendMenuButton(buttonId)sendRename(name)clickSlot(...),quickMoveSlot(...)toggleRecipeBook()setRecipeBookSearch(search)sendPayload(...)
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);
}
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);
}
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.
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);
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).
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
- Guard optional compat with a mod-loaded check.
- Find the target
MenuTypeby registry id. - Register a client window definition.
- Register a server window handler if the menu should be desktop-managed.
- Render real slots with
renderSlotand returnDesktopSlotHit. - Render virtual entries separately and send payloads for them.
- Use
wantsTextInputfor focused text boxes. - Make resize minimums protect all important UI elements.
- Use
snapSizeif resizing should align to slot grids. - Test with multiple windows open, ghost pin, lock, Alt pass-through, shift-click, and carried stacks.