MODFORGEWORLD
← Knowledge base

XUi - Window System

xui-window-systemv1updated 5h ago

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_group name is what you pass to windowManager.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 keys
  • Config/XUi/xui.xml — window group registrations
  • Config/XUi/styles.xml — named color tokens
  • Config/XUi/controls.xml — reusable control templates
  • Config/Localization.txt — label strings
  • UIAtlases/ItemIconAtlas/*.png and UIAtlases/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 .dll files (controllers, Harmony patches, IModApi hooks) — DLLs are loaded once per process
  • Config/items.xml, blocks.xml, recipes.xml, etc. — game data, baked on load

Typical XML-edit iteration loop

  1. Edit the XML / atlas PNG in the mod source directory.
  2. Copy the changed file(s) into the game's Mods/<ModName>/ tree (e.g. run deploy.sh without quitting, or cp the single file).
  3. Open the console (F1) and run xui reload.
  4. 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-open windowpaging if it was closed, and closing a sibling content group elsewhere in your handler can still cascade windowpaging shut.

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 MyTabWindowSelector 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

AttributeValuesNotes
namestringUnique identifier
width / heightintPixel dimensions
pos"x,y"Offset from anchor point
anchorCenterCenter, LeftTop, RightBottom, etc.NGUI-style anchor — CenterCenter centers on screen
panel"Center", "Left", "Right"Which UI panel this window belongs to
controllerclass name (without XUiC_ prefix)C# controller class
cursor_area"true"Shows cursor when window is open
depthintZ-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 with fill="0.75")

Key attributes:

AttributeNotes
color"R,G,B,A" (0–255) or named token like [black]
type"sliced", "filled", or "FlippedHorizontal"
spriteOptional named sprite from the UI atlas (e.g. "menu_empty3px")
atlasOverride 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
globalopacitymodFloat multiplier applied to inherited opacity
foregroundlayer"true" — always renders in front of other elements
pivot"center", "left", "right", "top", "bottom" — rotation/anchor pivot point
rotationDegrees (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]" />
AttributeNotes
textLiteral string, localization key binding {key}, or data binding {bindingName}
text_keyDirect localization key (alternative to text="{key}")
font_sizeInteger
color"R,G,B,A" or named token
justify"left", "center", "right"
stylee.g. "outline" adds text shadow
upper_case"true" forces uppercase
effect"outline" — outline/shadow effect on text
effect_colorColor of the effect: "R,G,B,A"
effect_distanceOffset of the effect: "x,y" (e.g. "1,1")
overflowHow 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,255 for a hard black outline).
  • effect_distance="1,1" — pixel offset; 1,1 is a tight HUD-style shadow, 2,2 is a heavier readout, 0,1 is 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" />
AttributeNotes
textureTexture asset path or binding
materialUnity material (e.g. "Materials/Transparent Colored")
original_aspect_ratio"true" preserves aspect ratio on resize
colorTint color
globalopacityWhether 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" />
AttributeNotes
search_field"true" — connects to sibling grid for live filtering
focus_on_open"true" — auto-focuses when window opens
character_limitMax character count
virtual_keyboard_promptPrompt 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 .Text yourself in OnClose.
  • OnChangeHandler(XUiController _sender, string _text, bool _changeFromCode); setting .Text from 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 — the name of the <grid> this pager controls
  • visible_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]":

TokenApproximate 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
  • depth controls draw order (higher = drawn on top)
  • anchor on 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) — toggle IsVisible from the controller to mark the active/selected item.
  • full-window texture (e.g. brushed metal): plain <sprite sprite="mytex"/> stretched to window size; omit type="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.Drawing FAILS under pwsh 7 (System.Drawing.Common on .NET 10 forwards to a private System.Private.Windows.GdiPlus assembly 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.matrix to scale
  • panelH ≤ 280 (at 2x scale = 560px) — keeps the window within the HUD bars
  • DontDestroyOnLoad — prevents the GameObject from being destroyed on scene changes
  • Input.ResetInputAxes() — prevents player movement while the window is open
  • SetControllable(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
  • OnGUI runs multiple times per frame (Layout + Repaint passes). Never use Input.GetMouseButtonDown() or manual Input.mousePosition hit-testing inside OnGUI — they behave inconsistently across passes. For hold-to-repeat buttons, use GUI.RepeatButton() and gate on Event.current.type == EventType.Repaint to fire once per frame. Do not build custom hold-tracking with Input.GetMouseButton() — it fires on every pass and accumulates incorrectly.

When to Use XUi vs IMGUI

Use CaseApproach
Clickable lists, text-heavy windowsIMGUI — text always renders correctly
Simple buttons with icons onlyXUi works fine (no text rendering issues)
Integration with vanilla UI (toolbelt, HUD)XUi required (must patch existing windows)
Quick prototypingIMGUI — 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, so GetChildrenByType<RowController> finds only 1 entry regardless of rows=. Always add repeat_content="true" to <grid> elements that use a single template row. Also set visible="false" on the template so empty rows stay hidden until SetData makes them visible.

<rect> does not render visually. Putting color= or sprite= on a <rect> does nothing useful. Use a <sprite> child element inside the rect for any background fill.

The window name in windows.xml must match the <window name=""> in xui.xml — the window_group name is what you pass to windowManager.Open(), but the <window name=""> child inside the group must exactly match the name attribute in windows.xml. The controller attribute 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 calls GetBindingValue through base-class references, so mod overrides via new are not dispatched. Labels will show as white rectangles if they have binding text that doesn't resolve. Use text="" (empty) in XML and set text programmatically via XUiV_Label.Text in 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 the color tint is not applied to rect elements — only to <sprite> elements.

open_anywhere="true" is not a valid window attribute in 7DTD XUi. Remove it.

GetBindingValue in XUiController is not virtual. You must use new (method hiding), not override. The game resolves it via the concrete type. Using override causes a compile error.

Controller class naming: The controller="Foo" attribute in XML maps to the C# class Foo exactly — no prefix is added for mod assemblies. Your class must be named Foo, not XUiC_Foo.

Filled sprites for progress bars: Use type="filled" on a <sprite> element to create a progress bar. The fill attribute (0.0–1.0) controls how much is visible. In C#, access via XUiV_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. Set fillcenter="true" or fillcenter="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 set sprite="..." (plus atlas="..." 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 the color gotcha below). Always drive the icon via the button's own sprite= attribute instead.

color on <button> does NOT tint the background. Buttons always render with their default white/light sprite regardless of the color attribute. To get readable text on buttons, use a separate <label> child with explicit depth above the button and color="[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, and style="press" makes the rect clickable via OnPress.

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 and pivot="center" on each <button>. When switching a button to center pivot, move pos to 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, use pos="-22,11" rather than pos="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, literal text="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) width matching the parent rect's width, and (3) pos="0,0" or vertical offset calculated as (rectHeight - labelHeight) / 2 for 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 width must equal the rect width for justify="center" to center properly. If the label has a left-offset pos (e.g. pos="10,0") or a narrower width than its parent, the text will appear off-center. Do NOT use <button> for styled buttons — it renders a white background that ignores the color attribute.

Changing an XUi controller class to a non-XUiController (e.g. MonoBehaviour) causes InvalidCastException in XUiFromXml.LoadXui. If a class name previously used as a controller="" attribute still exists in the DLL but no longer extends XUiController, 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.Update deliberately 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 — record Time.time when you call Open() and ignore commands arriving within ~0.35s (vanilla's toolbelt radial uses the same trick with 0.4s in HandleToolbeltCommand). Releasing after the wheel is visible with nothing hovered correctly fires nothing.
  • radialButtonPressed treats 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.