XUi - Window System
XUi - Window System
Part of the 7DTD Modding Knowledgebase. Covers the XML side of 7DTD's XUi UI framework — element types, attributes, layout, and rendering.
How Windows Are Registered
Windows are added in two places:
1. Config/XUi/windows.xml — defines the window element tree (layout, visuals, controllers)
<configs>
<append xpath="/windows">
<window name="myWindow" width="700" height="500"
pos="0,0" anchor="CenterCenter" panel="Center"
controller="MyWindowController"
cursor_area="true">
<!-- elements here -->
</window>
</append>
</configs>
2. Config/XUi/xui.xml — registers the window in the XUi ruleset so it can be opened
<configs>
<append xpath="/xui/ruleset[@name='default']">
<window_group name="myWindowGroup" controller="MyWindowGroupController">
<window name="myWindow" />
</window_group>
</append>
</configs>
The
window_groupname is what you pass towindowManager.Open("myWindowGroup").
Hot-Reloading XUi (no game restart)
The in-game console command is xui reload (two words, with a space — not xuireload). It re-parses every window group's XML tree from disk and rebuilds the live UI, usually in well under a second. Look for this line in the log to confirm:
INF [XUi] Parsing all window groups completed in NNN ms total.
What reloads, what doesn't
Reloads live:
Config/XUi/windows.xml— layouts, positions, colors, depths, text keysConfig/XUi/xui.xml— window group registrationsConfig/XUi/styles.xml— named color tokensConfig/XUi/controls.xml— reusable control templatesConfig/Localization.txt— label stringsUIAtlases/ItemIconAtlas/*.pngandUIAtlases/UIAtlas/*.png— custom sprites (close and reopen the window to force the atlas lookup to re-fetch)
Does not reload — still requires a full restart:
- Mod
.dllfiles (controllers, Harmony patches,IModApihooks) — DLLs are loaded once per process Config/items.xml,blocks.xml,recipes.xml, etc. — game data, baked on load
Typical XML-edit iteration loop
- Edit the XML / atlas PNG in the mod source directory.
- Copy the changed file(s) into the game's
Mods/<ModName>/tree (e.g. rundeploy.shwithout quitting, orcpthe single file). - Open the console (
F1) and runxui reload. - Close and reopen the affected window to see the change.
Triggering from outside the game (7debug)
If 7debug is installed, you can hot-reload remotely (from a terminal, script, or AI agent) without ever focusing the game window:
curl -s -X POST http://localhost:7860/api/command \
-H "Content-Type: application/json" \
-d '{"command":"xui reload"}'
Expect {"command":"xui reload","output":[]} in the HTTP response — xui reload prints progress to the game log rather than the command-capture buffer. Verify by reading /api/console and looking for the Parsing all window groups completed line.
This lets an agent iterate on an XUi layout without paying the multi-minute quit → launch → load-save cycle every time.
windowManager.Open(name, bool) — what the second arg does
GUIWindowManager.Open(string name, bool playOpenSound = true) — the second bool is not "close other groups." Observed behavior from clicking a custom paging-header button that calls wm.Open("myGroup", <bool>):
true— closes the compass, the toolbelt hint area,windowpaging(the top paging-icon strip), and any currently-open sibling content group before opening. The opened group renders "alone."false— leaves the compass open. Does not re-openwindowpagingif it was closed, and closing a sibling content group elsewhere in your handler can still cascadewindowpagingshut.
So false is necessary but not sufficient to keep the top paging header visible alongside a custom content group. You also have to avoid a path that lets windowpaging close in the first place. The clean fix is to let vanilla WindowSelector (the controller on windowPagingHeader) handle the click rather than going through wm.Open directly — see the paging-button section below.
Adding a button to windowPagingHeader
Vanilla windowPagingHeader has controller="WindowSelector". WindowSelector auto-wires every child <button> so that clicking the button opens a window_group whose name matches the button name lowercased (e.g. button Skills → group skills).
Do not give your button its own controller= attribute — that preempts WindowSelector's auto-wiring and forces you into a manual wm.Open path which closes windowpaging as a side effect. Just rely on the naming convention:
<append xpath="/windows/window[@name='windowPagingHeader']">
<button pos="171,-21" name="MyTab"
sprite="my_icon"
tooltip_key="xuiMyTab"
style="press, hover, paging.window.icon" />
</append>
<window_group name="mytab" controller="MyTabWindowGroup, MyMod">
<window name="windowMyTabList" />
</window_group>
Button name MyTab → WindowSelector opens group mytab. The header stays visible because WindowSelector routes through its paging-aware internal flow rather than a raw wm.Open.
Do not add windowPagingHeader as a <window> child of your own group. WindowSelector's OnOpen auto-selects a default tab (first child button, usually Crafting), which calls wm.Open("crafting", true), which closes your group, which tries to pop an action set that no longer belongs to it — LocalPlayerInput::Pop - Tried to pop a different action set — and the cascade loops until the native stack overflows.
Window Attributes
| Attribute | Values | Notes |
|---|---|---|
name | string | Unique identifier |
width / height | int | Pixel dimensions |
pos | "x,y" | Offset from anchor point |
anchor | CenterCenter, LeftTop, RightBottom, etc. | NGUI-style anchor — CenterCenter centers on screen |
panel | "Center", "Left", "Right" | Which UI panel this window belongs to |
controller | class name (without XUiC_ prefix) | C# controller class |
cursor_area | "true" | Shows cursor when window is open |
depth | int | Z-order |
open_anywhere="true"is not a valid attribute — omit it.
Gotcha: pos and anchor are ignored on <window panel="Center">
When a window uses panel="Center", the Center panel auto-centers it and the pos and anchor attributes on the <window> element have no visible effect. Changing pos="0,0" to pos="300,-200" or anchor="CenterCenter" to anchor="CenterBottom" produces the same render.
Confirmed empirically: only child-element pos attributes shift things on screen. The window itself is locked to its panel.
Workaround — shift content inside the window with a <rect> wrapper:
<window name="myWindow" width="280" height="900" pos="0,0" anchor="CenterCenter"
panel="Center" ...>
<rect name="contentShift" pos="0,-160" width="280" height="580">
<!-- all original children here, unchanged -->
</rect>
</window>
The rect's pos shifts its children visually (child pos is relative to parent). Increase the window's height if necessary so the shifted content doesn't clip against the window's bottom edge.
This is the only reliable way to reposition a panel="Center" window's visible content — modifying pos/anchor on the window itself does nothing.
Element Types
<rect> — Layout Container
<rect> is a purely structural container. It has no visual rendering of its own. Do not put color= or sprite= attributes on it expecting it to render — those are ignored or cause white-box artifacts.
<rect depth="2" pos="15,-50" width="460" height="400">
<!-- visual children go here -->
</rect>
Use it to group and position child elements.
<sprite> — Visual Background / Image
<sprite> renders a visual. This is what you use for colored backgrounds, borders, icons, and filled bars.
<!-- Solid dark background filling parent rect -->
<sprite depth="1" color="32,32,32,240" type="sliced" />
<!-- Border-only box using a 9-slice border sprite -->
<sprite depth="1" sprite="menu_empty3px" color="[black]" type="sliced" fillcenter="false" />
<!-- Named-color solid fill -->
<sprite depth="2" color="[darkGrey]" type="sliced" />
<!-- Interactive hover highlight (used inside clickable rows) -->
<sprite depth="1" color="[darkGrey]" type="sliced" on_hover="true" />
Key type values:
"sliced"— 9-slice sprite, scales cleanly to any size (most common for backgrounds)"filled"— used for progress bars (combine withfill="0.75")
Key attributes:
| Attribute | Notes |
|---|---|
color | "R,G,B,A" (0–255) or named token like [black] |
type | "sliced", "filled", or "FlippedHorizontal" |
sprite | Optional named sprite from the UI atlas (e.g. "menu_empty3px") |
atlas | Override sprite atlas: "UIAtlas", "ItemIconAtlas", "UIHelpWindow", "PrefabImages", "UIMods" |
fillcenter | "false" renders only the border, not the fill (border-box effect) |
on_hover | "true" — highlights on mouse-over |
on_press | "true" — visual feedback on click |
globalopacity | "false" — does NOT inherit parent opacity |
globalopacitymod | Float multiplier applied to inherited opacity |
foregroundlayer | "true" — always renders in front of other elements |
pivot | "center", "left", "right", "top", "bottom" — rotation/anchor pivot point |
rotation | Degrees (e.g. "45") — rotates around pivot |
flip | "true" — mirrors the sprite |
<filledsprite> — Progress Bars
<filledsprite depth="3" color="255,0,0,160" type="filled" fill="{healthPercent}" />
fill accepts 0–1 float or a binding.
<label> — Text
<label depth="2" pos="10,-12" width="400" height="30"
text="Hello World" font_size="20"
color="[white]" />
<!-- Localized text via key -->
<label text="{my_localization_key}" font_size="20" color="[white]" />
| Attribute | Notes |
|---|---|
text | Literal string, localization key binding {key}, or data binding {bindingName} |
text_key | Direct localization key (alternative to text="{key}") |
font_size | Integer |
color | "R,G,B,A" or named token |
justify | "left", "center", "right" |
style | e.g. "outline" adds text shadow |
upper_case | "true" forces uppercase |
effect | "outline" — outline/shadow effect on text |
effect_color | Color of the effect: "R,G,B,A" |
effect_distance | Offset of the effect: "x,y" (e.g. "1,1") |
overflow | How to handle text that exceeds bounds |
parse_actions | "true" — enables parsing of inline action links |
Inline text color formatting:
<!-- Wrap text in [HEXCODE]...[-] to colorize inline -->
<label text="[DECEA3]Price[-]: 100" />
<label text="[FFD200]Gold[-] or [FF2400]Red[-]" />
HUD-style black drop shadow / outline on text:
For light labels that sit over an unpredictable background (wallpaper, game world, photo) — e.g. iOS-style app labels, floating HUD readouts — add a black outline so the text stays readable without a solid panel behind it. This is the same treatment the vanilla DAY: N TIME: hh:mm temp°F strip uses.
<label name="appLabel" pos="20,-208" width="100" height="22"
font_size="18" color="250,250,252,255" justify="center"
effect="outline" effect_color="0,0,0,255" effect_distance="1,1"
text_key="zPhone_AppRetroZed" depth="5" />
effect="outline"— enables the outline/shadow pass ("shadow"is also accepted for a one-sided drop shadow).effect_color— the outline color (0,0,0,255for a hard black outline).effect_distance="1,1"— pixel offset;1,1is a tight HUD-style shadow,2,2is a heavier readout,0,1is a drop shadow only below the text.
The style="outline" alternative attribute in the table above is a shortcut that points at the same effect but with less control over color and distance.
<button> — Clickable Button
<!-- Icon button -->
<button name="btnCraft" depth="2" pos="30,-20" style="icon30px, press, hover"
sprite="ui_game_symbol_hammer" pivot="center" sound="[paging_click]" />
<!-- Text button -->
<button name="btnPrint" depth="3" pos="490,-380" width="195" height="50"
font_size="22" text="Print" sound="craft_click_craft" />
- Wired to C# via
GetChildById("btnPrint").OnPress += handler - The
style="press"attribute gives visual press feedback - Sound plays on click
<grid> — Repeating List / Grid
<grid name="imageGrid" depth="3" pos="5,-30" rows="12" cols="1"
cell_width="450" cell_height="30"
controller="PixelPasteImageGridController">
<!-- template element (one per row) -->
<rect name="imageEntry" width="450" height="28" depth="4"
style="press"
controller="PixelPasteImageEntryController">
<sprite depth="1" color="[darkGrey]" type="sliced" on_hover="true" />
<label name="lblText" depth="2" pos="10,-7" width="430" height="24"
font_size="16" color="[white]" text="{rowBinding}" />
</rect>
</grid>
- The child element is a template — cloned once per row
rows×cols= max visible entries- Child controller handles per-row data via
SetFileInfo()/RefreshBindings() repeat_content="true"— the template row is auto-cloned to fill the grid (alternative to defining each row manually)arrangement="vertical"(default) fills top-to-bottom;arrangement="horizontal"fills left-to-right- Pair with a
<textfield search_field="true">sibling to get live text filtering for free
<panel> — Themed Container
A higher-level container that supports the game's theming system.
<panel name="header" height="43" depth="1" backgroundspritename="ui_game_panel_header">
<label style="header.name" text="{title}" />
</panel>
Used for headers and standard game UI chrome.
<texture> — Raw Texture Rendering
Renders a Unity texture directly, useful for custom images loaded from mod resources.
<texture name="preview" pos="0,-10" width="128" height="128"
material="Materials/Transparent Colored"
depth="3" />
| Attribute | Notes |
|---|---|
texture | Texture asset path or binding |
material | Unity material (e.g. "Materials/Transparent Colored") |
original_aspect_ratio | "true" preserves aspect ratio on resize |
color | Tint color |
globalopacity | Whether to inherit global UI opacity |
<textfield> — Text Input
<textfield name="searchBox" pos="5,-5" width="280" height="36"
font_size="18" depth="3"
search_field="true"
focus_on_open="true"
character_limit="64" />
| Attribute | Notes |
|---|---|
search_field | "true" — connects to sibling grid for live filtering |
focus_on_open | "true" — auto-focuses when window opens |
character_limit | Max character count |
virtual_keyboard_prompt | Prompt text for controller virtual keyboard |
open_vk_on_open | "true" — opens virtual keyboard on open |
close_group_on_tab | "true" — Tab closes the window group |
clear_button | "true" — shows an X button to clear text |
Code side: GetChildById("...") as XUiC_TextInput. Events (verified against Assembly-CSharp, V2/3-era):
OnSubmitHandler—(XUiController _sender, string _text), fires on Enter only. Clicking away does NOT commit; if you need commit-on-close, read.Textyourself inOnClose.OnChangeHandler—(XUiController _sender, string _text, bool _changeFromCode); setting.Textfrom code fires this with_changeFromCode=true(it does not fire OnSubmitHandler), so guard against feedback loops.OnInputAbortedHandler—(XUiController _sender), fires on Esc.
Numeric-input pattern (used by the Elevator settings Height column): parse in the submit handler, apply through the same validation as the +/- buttons, then re-Populate so the field snaps back to the value that actually took effect on refusal.
<combobox> — Numeric Stepper / Dropdown
<combobox name="qualityPicker" pos="10,-10" width="120" height="36"
type="ComboBoxInt"
format_string="{0}"
value_min="1" value_max="6"
value_wrap="true" value_increment="1" />
<togglebutton> — Toggle Button
<togglebutton name="toggleLocked" pos="10,-10" width="160" height="36"
caption_key="btnLockLabel" tooltip_key="btnLockTooltip"
depth="3" style="default" />
<pager> / <scroll_paging> — Pagination Controls
<!-- Basic pager linking to a grid -->
<pager name="listPager" pos="0,-5" contents_parent="imageGrid" />
<!-- Pager with page number display -->
<scroll_paging pos="0,-420" width="460" height="36"
show_max_page="true" separator=" / "
primary_pager="listPager"
contents_parent="imageGrid"
visible_max_rows="12" />
contents_parent— thenameof the<grid>this pager controlsvisible_max_rows— how many rows are shown per page
Named Color Tokens
These are defined globally in styles.xml and usable anywhere with color="[tokenName]":
| Token | Approximate color |
|---|---|
[white] | White |
[black] | Black |
[lightGrey] | Light grey |
[mediumGrey] | Medium grey |
[darkGrey] | Dark grey |
[red] | Red |
[beige] | Warm off-white |
[iconColor] | Default icon tint |
[labelColor] | Default label text |
[disabledLabelColor] | Greyed-out label |
Layout Rules
pos="x,y"— offset from the top-left of the parent element- Y is negative going down:
pos="10,-50"means 10px right, 50px down depthcontrols draw order (higher = drawn on top)anchoron a<window>controls which point of the screen it's pinned to
Binding Expressions
Standard bindings use {bindingName} to resolve a string from the nearest controller. For complex logic, use the expression syntax {# ... }:
<!-- Simple binding -->
<label text="{itemcount}" />
<!-- Negation -->
<sprite visible="{# !showEmpty}" />
<!-- String comparison -->
<label visible="{# prefabNight == ''}" />
<!-- Numeric comparison with cast -->
<button visible="{# int(pageNumber) > 1}" />
<!-- Ternary operator -->
<label justify="{# hasDurability ? 'Center' : 'Right'}" />
Expression syntax supports: !, ==, !=, >, <, >=, <=, &&, ||, ternary ? :, and the int() cast function.
Custom Controls (Template Variables)
Controls defined in controls.xml support ${var} template variables for parameterized instances:
<!-- Define in controls.xml -->
<controls>
<my_stat_row depth="${depth}" pos="${pos}" width="${width}" height="${height}"
visible="${visible}">
<rect pos="${pos}" width="${width}" height="${height}">
<label text="${label}" font_size="${font_size}" />
</rect>
</my_stat_row>
</controls>
<!-- Use in windows.xml — pass variable values as attributes -->
<my_stat_row name="row0" depth="3" pos="0,-50" width="400" height="30"
label="Damage" font_size="18" visible="true" />
Template variables only work as attribute values — they cannot be used in XPath expressions.
Conditional Patching
Apply patches only when certain mods are (or aren't) loaded:
<configs>
<conditional>
<if cond="mod_loaded('OtherModName')">
<!-- These patches only apply if OtherModName is loaded -->
<set xpath="/xui/ruleset[@name='default']/@scale">1.245</set>
</if>
</conditional>
<conditional>
<if cond="not mod_loaded('OtherModName')">
<!-- These patches only apply if OtherModName is NOT loaded -->
<append xpath="/windows">
<window name="myFallbackWindow" ... />
</append>
</if>
</conditional>
</configs>
This is how UI overhaul mods handle compatibility with each other.
Common Patterns
Dark background panel with border
<sprite depth="1" sprite="menu_empty3px" color="[black]" type="sliced" fillcenter="false" />
<sprite depth="2" color="32,32,32,240" type="sliced" />
Clickable list row with hover highlight
<rect name="rowEntry" width="450" height="28" depth="4" style="press"
controller="MyRowController">
<sprite depth="1" color="[darkGrey]" type="sliced" on_hover="true" />
<label name="lblText" depth="2" pos="10,-7" width="430" height="24"
font_size="16" color="[white]" text="{rowText}" />
</rect>
Section header bar
<rect depth="2" width="700" height="40" pos="0,0">
<sprite depth="1" color="20,20,20,255" type="sliced" />
<label depth="2" pos="15,-12" width="500" height="30"
text="Section Title" font_size="24" color="[white]" />
</rect>
Round buttons / skeuomorphic panels via custom UIAtlas sprites
XUi has no circular primitive — ship PNGs in UIAtlases/UIAtlas/ (sprite name =
filename) and layer them. Pattern used by the Elevator mod's cab-style floor
picker (tools/gen_ui_sprites.ps1 there generates the art procedurally with
System.Drawing — deterministic seed, no art tools needed):
disc.png: dark radial-gradient circle with a bright rim, transparent corners.glow.png: soft white ring slightly LARGER than the disc (art the ring radius a few % outside the disc's alpha edge, or the opaque disc hides it) — toggleIsVisiblefrom the controller to mark the active/selected item.- full-window texture (e.g. brushed metal): plain
<sprite sprite="mytex"/>stretched to window size; omittype="sliced".
<rect name="btnFloor0" depth="3" pos="34,-22" width="232" height="48" style="press" sound="[paging_click]">
<sprite depth="4" name="glow0" sprite="elev_btn_glow" pos="-4,4" width="56" height="56" visible="false"/>
<sprite depth="5" sprite="elev_btn_circle" pos="2,-2" width="44" height="44"/>
<label depth="6" name="num0" pos="2,-13" width="44" height="22" font_size="18" justify="center" text=""/>
<label depth="6" name="lbl0" pos="64,-15" width="168" height="22" font_size="16" justify="left" text=""/>
</rect>
Child pos may be negative/above the parent rect (the glow overhangs by 4px) —
positions are plain offsets; nothing clips outside a scroll view.
Generating atlas PNGs from PowerShell:
Add-Type -TypeDefinition $cs -ReferencedAssemblies System.DrawingFAILS under pwsh 7 (System.Drawing.Common on .NET 10 forwards to a privateSystem.Private.Windows.GdiPlusassembly that Add-Type can't reference). Run the generator under Windows PowerShell 5.1 instead:powershell.exe -NoProfile -ExecutionPolicy Bypass -File gen.ps1.
Controls (Reusable Templates)
Reusable UI components are defined in Config/XUi/controls.xml (or XUi_Common/controls.xml for cross-context sharing). These work like named templates you can reference from windows.
<!-- Define a reusable component in controls.xml -->
<controls>
<include name="my_row_entry">
<rect width="450" height="28">
<sprite depth="1" color="[darkGrey]" type="sliced" on_hover="true" />
<label name="lblText" depth="2" pos="10,-7" width="430" height="24"
font_size="16" color="[white]" text="{rowText}" />
</rect>
</include>
</controls>
Then reference it in windows.xml with the element name matching the name attribute:
<my_row_entry name="row0" />
Custom Style Tokens (styles.xml)
You can define custom named color tokens in Config/XUi/styles.xml (or XUi_Common/styles.xml) and then use them like built-in tokens:
<styles>
<style name="MyDarkRed" color="42,16,16,255" />
<style name="MyWindowBg" color="0,0,0,220" />
</styles>
Use in windows:
<sprite color="[MyDarkRed]" type="sliced" />
Removing Existing Windows
To remove a vanilla window (e.g. to replace it with a custom version), use a <remove> XPath patch in your windows.xml:
<configs>
<remove xpath="/windows/window[@name='windowToolbelt']" />
<append xpath="/windows">
<window name="windowToolbelt" ...>
<!-- your replacement -->
</window>
</append>
</configs>
This is how Advanced UI mods fully replace HUD elements.
XUi Ruleset Scale
In xui.xml you can set the global UI scale for the ruleset:
<configs>
<set xpath="/xui/ruleset[@name='default']/@scale">1.14</set>
</configs>
Recommended Alternative: Unity IMGUI (OnGUI)
XUi has persistent, hard-to-debug issues with text rendering inside <button> and <rect> containers — labels inside buttons are frequently invisible regardless of depth, color, or layout settings. After extensive testing, Unity IMGUI (OnGUI) is the recommended approach for mod windows that need clickable lists, text, or interactive elements.
IMGUI bypasses XUi entirely and renders reliably every time. The 7Window ScreenAlignTool uses this approach successfully.
IMGUI Window Pattern (Known Good Starting Point)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
[Preserve]
public class MyModBrowserIMGUI : MonoBehaviour
{
private static MyModBrowserIMGUI _instance;
public static MyModBrowserIMGUI Instance
{
get
{
if (_instance == null)
{
var go = new GameObject("MyModBrowserIMGUI");
_instance = go.AddComponent<MyModBrowserIMGUI>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
private bool _active;
private Vector2 _scrollPos;
private GUIStyle _labelStyle, _buttonStyle, _titleStyle, _boxStyle;
private bool _stylesInit;
public void Open()
{
_active = true;
_scrollPos = Vector2.zero;
var player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null) player.SetControllable(false);
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
}
public void Close()
{
_active = false;
var player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null) player.SetControllable(true);
}
void Update()
{
if (!_active) return;
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
if (Input.GetKeyDown(KeyCode.Escape)) Close();
Input.ResetInputAxes();
}
void OnGUI()
{
if (!_active) return;
InitStyles();
// 2x scale for readability
const float scale = 2f;
var oldMatrix = GUI.matrix;
GUI.matrix = Matrix4x4.TRS(
Vector3.zero, Quaternion.identity, new Vector3(scale, scale, 1f));
float panelW = 320, panelH = 280;
float px = (Screen.width / scale - panelW) / 2f;
float py = (Screen.height / scale - panelH) / 2f;
// Dark background (doubled for opacity)
GUI.Box(new Rect(px - 10, py - 10, panelW + 20, panelH + 20), "", _boxStyle);
GUI.Box(new Rect(px - 10, py - 10, panelW + 20, panelH + 20), "", _boxStyle);
float y = py;
// Title + close button
GUI.Label(new Rect(px, y, panelW - 60, 30), "My Window", _titleStyle);
if (GUI.Button(new Rect(px + panelW - 30, y, 30, 25), "X", _buttonStyle))
Close();
y += 35;
// Scrollable list
float listH = panelH - 50;
float rowH = 32;
int itemCount = 10; // your data count
float contentH = itemCount * rowH;
_scrollPos = GUI.BeginScrollView(
new Rect(px, y, panelW, listH), _scrollPos,
new Rect(0, 0, panelW - 20, contentH));
for (int i = 0; i < itemCount; i++)
{
float ry = i * rowH;
if (i % 2 == 0) GUI.Box(new Rect(0, ry, panelW - 20, rowH), "");
if (GUI.Button(new Rect(0, ry, panelW - 20, rowH), "", GUIStyle.none))
OnItemClicked(i);
GUI.Label(new Rect(8, ry + 4, panelW - 40, rowH - 8),
"Item " + i, _labelStyle);
}
GUI.EndScrollView();
GUI.matrix = oldMatrix;
}
private void OnItemClicked(int index) { /* handle click */ }
private void InitStyles()
{
if (_stylesInit) return;
_stylesInit = true;
_labelStyle = new GUIStyle(GUI.skin.label)
{ fontSize = 14 };
_labelStyle.normal.textColor = Color.white;
_buttonStyle = new GUIStyle(GUI.skin.button)
{ fontSize = 14 };
_titleStyle = new GUIStyle(GUI.skin.label)
{ fontSize = 18, fontStyle = FontStyle.Bold };
_titleStyle.normal.textColor = new Color(0.78f, 0.86f, 1f);
_boxStyle = new GUIStyle(GUI.skin.box);
}
}
Opening from a Block
// In your Block class OnBlockActivated:
MyModBrowserIMGUI.Instance.Open();
No XUi XML registration needed — no windows.xml, no xui.xml, no window groups.
Key IMGUI Rules
- Scale 2x — game resolution makes default IMGUI tiny; use
GUI.matrixto scale - panelH ≤ 280 (at 2x scale = 560px) — keeps the window within the HUD bars
DontDestroyOnLoad— prevents the GameObject from being destroyed on scene changesInput.ResetInputAxes()— prevents player movement while the window is openSetControllable(false/true)— locks/unlocks player movement and camera- Singleton pattern — one instance, created on first access
[Preserve]attribute — prevents Unity IL stripping- MainThreadDispatcher — if using background threads (e.g. TCP fetches), you must initialize a dispatcher MonoBehaviour and use it to marshal callbacks to the main thread
OnGUIruns multiple times per frame (Layout + Repaint passes). Never useInput.GetMouseButtonDown()or manualInput.mousePositionhit-testing insideOnGUI— they behave inconsistently across passes. For hold-to-repeat buttons, useGUI.RepeatButton()and gate onEvent.current.type == EventType.Repaintto fire once per frame. Do not build custom hold-tracking withInput.GetMouseButton()— it fires on every pass and accumulates incorrectly.
When to Use XUi vs IMGUI
| Use Case | Approach |
|---|---|
| Clickable lists, text-heavy windows | IMGUI — text always renders correctly |
| Simple buttons with icons only | XUi works fine (no text rendering issues) |
| Integration with vanilla UI (toolbelt, HUD) | XUi required (must patch existing windows) |
| Quick prototyping | IMGUI — no XML files, no registration |
Gotchas & Lessons Learned
Grid templates are NOT auto-cloned without
repeat_content="true"— without this attribute the template child element exists exactly once, soGetChildrenByType<RowController>finds only 1 entry regardless ofrows=. Always addrepeat_content="true"to<grid>elements that use a single template row. Also setvisible="false"on the template so empty rows stay hidden untilSetDatamakes them visible.
<rect>does not render visually. Puttingcolor=orsprite=on a<rect>does nothing useful. Use a<sprite>child element inside the rect for any background fill.
The
window nameinwindows.xmlmust match the<window name="">inxui.xml— thewindow_group nameis what you pass towindowManager.Open(), but the<window name="">child inside the group must exactly match thenameattribute inwindows.xml. Thecontrollerattribute is independent — it maps to the C# class name. These are three different names that serve different purposes:<!-- windows.xml: defines the window layout --> <window name="myWindow" controller="MyController" ...> <!-- xui.xml: registers the window group --> <window_group name="myWindowGroup"> <window name="myWindow" /> <!-- must match windows.xml name --> </window_group> <!-- C#: open by window_group name --> playerUI.windowManager.Open("myWindowGroup", true);
Labels with
text="{binding}"won't render in mod controllers — the binding system callsGetBindingValuethrough base-class references, so mod overrides vianeware not dispatched. Labels will show as white rectangles if they have binding text that doesn't resolve. Usetext=""(empty) in XML and set text programmatically viaXUiV_Label.Textin the controller. See XUi - Controllers (C#).md for the workaround.
Adding
sprite="UISprite"to a<rect>will render a plain white box that covers everything underneath, since thecolortint is not applied to rect elements — only to<sprite>elements.
open_anywhere="true"is not a valid window attribute in 7DTD XUi. Remove it.
GetBindingValueinXUiControlleris not virtual. You must usenew(method hiding), notoverride. The game resolves it via the concrete type. Usingoverridecauses a compile error.
Controller class naming: The
controller="Foo"attribute in XML maps to the C# classFooexactly — no prefix is added for mod assemblies. Your class must be namedFoo, notXUiC_Foo.
Filled sprites for progress bars: Use
type="filled"on a<sprite>element to create a progress bar. Thefillattribute (0.0–1.0) controls how much is visible. In C#, access viaXUiV_Sprite.Fill. Combine with a grey backing sprite at lower depth for the empty portion:<sprite depth="3" name="back" color="110,110,110,160" width="300" height="30" type="filled" fill="1"/> <sprite depth="5" name="sprFill" color="200,40,40,200" width="300" height="30" type="filled" fill="0"/>
Sliced sprites for UI panels: Use
type="sliced"for backgrounds and borders. This does 9-slice scaling so edges don't stretch. Common sprites:ui_game_panel_header,ui_game_panel_bg,menu_empty3px. Setfillcenter="true"orfillcenter="false"to control center fill.
Custom buttons with icons / transparent corners: A
<button>ships with a default white background sprite. If you place an icon<sprite>as a child of the button, any transparent pixels in the icon (e.g. rounded corners on a squircle app icon) will reveal that white bg underneath. The working fix is to setsprite="..."(plusatlas="..."if needed) directly on the<button>— the icon becomes the button's own visual, so there is no white bg layer above or below it. Example:<button name="appRetroZed" pos="20,-100" width="100" height="100" sprite="zphone_app_retrozed" atlas="ItemIconAtlas" depth="3" style="press" sound="[paging_click]" />The often-suggested inverse pattern — putting a
<sprite>first and layering an "invisible"<button color="0,0,0,0">on top to capture clicks — does not work in current 7DTD XUi: the button still renders its default white sprite and covers the icon entirely.color="0,0,0,0"does not hide the button's background (see thecolorgotcha below). Always drive the icon via the button's ownsprite=attribute instead.
coloron<button>does NOT tint the background. Buttons always render with their default white/light sprite regardless of thecolorattribute. To get readable text on buttons, use a separate<label>child with explicitdepthabove the button andcolor="[black]"(dark text on light button), rather than relying on the button's own text rendering or background tinting:<button name="btnMinus" pos="38,-29" width="44" height="34" depth="4" pivot="center" hoverscale="1.05" style="press, hover"> <label depth="8" pos="-22,11" width="44" height="24" justify="center" font_size="28" color="[black]" text="-" /> </button>For colored swatches (e.g. a palette), use
<rect style="press">with a<sprite color="R,G,B,A" type="sliced" />child instead of a<button>— sprites properly render the color, andstyle="press"makes the rect clickable viaOnPress.
Button hover growth should be small and center-anchored. The default hover effect can make tightly packed controls drift into neighbors or over the window edge. For compact option windows, set
hoverscale="1.05"or lower andpivot="center"on each<button>. When switching a button to center pivot, moveposto the button center instead of its top-left corner:centerX = left + width / 2,centerY = top - height / 2. Child labels are then relative to the centered origin too; for a 44x34 button with a 44x24 label and 6 px top padding, usepos="-22,11"rather thanpos="0,-6". Leave enough inner padding for the 5% growth on all sides.
Pad compact custom windows more than the minimum. If a button sits only a few pixels from a window border, hover scaling and selection boxes can look clipped or crowded. Use at least 12-16 px of side padding for small XUi control panels, and increase the window/content width rather than letting hover growth overlap the border.
Use literal
text="..."for fixed one-off labels when localization is not needed.text_key="..."is correct for translated UI, but if the key is missing/not loaded the UI shows the raw key (e.g.sentryLightBeamHeader). For fixed mod-only labels that do not need translation, literaltext="Beam Options"avoids a localization dependency.
Centering text on buttons: To make a clickable button with centered text, use a
<rect style="press">containing a<sprite>for the background and a<label>for the text. The label must have: (1)justify="center"for horizontal centering, (2)widthmatching the parent rect's width, and (3)pos="0,0"or vertical offset calculated as(rectHeight - labelHeight) / 2for vertical centering. Example:<rect name="btnAction" depth="6" pos="10,-10" width="120" height="32" style="press" sound="craft_click_craft"> <sprite depth="7" sprite="menu_empty3px" color="60,140,60,220" type="sliced" /> <label depth="8" pos="0,-1" width="120" height="32" font_size="15" color="255,255,255,255" justify="center" text="Click Me" /> </rect>Key points: the label
widthmust equal the rectwidthforjustify="center"to center properly. If the label has a left-offsetpos(e.g.pos="10,0") or a narrowerwidththan its parent, the text will appear off-center. Do NOT use<button>for styled buttons — it renders a white background that ignores thecolorattribute.
Changing an XUi controller class to a non-XUiController (e.g. MonoBehaviour) causes
InvalidCastExceptioninXUiFromXml.LoadXui. If a class name previously used as acontroller=""attribute still exists in the DLL but no longer extendsXUiController, the XUi loader may find it by name, try to cast it, and crash — even if no XML references it anymore. Rename the class to avoid conflicts.
Native Radial Wheel from C# (XUiC_Radial)
The vanilla radial wheel can be repurposed from mod code with custom entries — no XUi XML needed (learned in the FPV mod's pre-flight menu, 2026-07):
XUiC_Radial radial = ui.xui.RadialWindow; // LocalPlayerUI.GetUIForPrimaryPlayer()
radial.Open(); // match vanilla order: Open() BEFORE populating
radial.ResetRadialEntries();
radial.CreateRadialEntry(cmdIndex, "sprite_name", "ItemIconAtlas", "", "Label text");
radial.SetCommonData(UIUtils.ButtonIcon.RightStick, OnRadialCommand);
// handler: void OnRadialCommand(XUiC_Radial r, int commandIndex, XUiC_Radial.RadialContextAbs ctx)
- The wheel manages its own lifecycle when opened from a held key (e.g. Reload/R): stays open while the key is held, fires the handler for the highlighted entry on release.
- Quick-tap fires entry 0! The wheel only becomes visible after a 0.25s
displayDelay; if the key is released before that,XUiC_Radial.Updatedeliberately selects the FIRST enabled entry and fires it (vanilla's "tap = default action" shortcut). If your first entry shouldn't be a tap default, guard the handler with an elapsed-time check — recordTime.timewhen you callOpen()and ignore commands arriving within ~0.35s (vanilla's toolbelt radial uses the same trick with 0.4s inHandleToolbeltCommand). Releasing after the wheel is visible with nothing hovered correctly fires nothing. radialButtonPressedtreats ANY of Reload/Activate/Flashlight/Inventory/Swap/QuickMenu (incl.PermanentActions) as "still held" — you don't get to pick which key holds it open.- Entry count is flexible (2, 4, 8… all lay out fine).
- Custom icons: drop PNGs in
UIAtlases/ItemIconAtlas/and reference them by filename (no extension) with atlas"ItemIconAtlas". They do NOT have to be item icons — arbitrary UI glyphs work. 128×128 white-on-transparent reads well on the wheel. Vanilla sprites work too via atlas"UIAtlas"(e.g.ui_game_symbol_drone). - New atlas PNGs are baked at load — adding one needs a restart (atlas rebuild), not just xui reload.