MODFORGEWORLD
← Knowledge base

XUi - Controllers (C#)

xui-controllers-cv1updated 5h ago

XUi - Controllers (C#)

Part of the 7DTD Modding Knowledgebase. Covers the C# side of XUi — controller classes, bindings, events, and lifecycle.


Controller Hierarchy

XUiController              ← base class for all UI controllers
  └── XUiC_MyWindow        ← your window controller
  └── XUiC_MyGrid          ← grid/list controller
  └── XUiC_MyRow           ← per-row entry controller

Naming Convention

The controller="Foo" attribute in XML maps to the C# class Foo — the name must match exactly. The game does NOT add any prefix when resolving mod controller classes. (Vanilla game controllers internally use an XUiC_ prefix, but that is irrelevant for mod assemblies.)


Lifecycle Methods

public override void Init()
{
    base.Init();
    // Wire up buttons, initialize state.
    // Called once when the controller is created.
    XUiController btn = GetChildById("btnPrint");
    if (btn != null) btn.OnPress += OnPrintPressed;
}

public override void OnOpen()
{
    base.OnOpen();
    // Called every time the window is opened.
    // Refresh data here.
}

public override void OnClose()
{
    base.OnClose();
    // Called when window is closed.
}

Data Bindings

XUi uses a string-based binding system. Labels with text="{myBinding}" call GetBindingValue on their nearest controller ancestor to resolve the value.

// NOTE: GetBindingValue is NOT virtual in XUiController.
// You MUST use 'new' (method hiding), NOT 'override'.
// Using 'override' causes a compile error.
public new bool GetBindingValue(ref string _value, string _bindingName)
{
    switch (_bindingName)
    {
        case "selectedFile":
            _value = selectedFile;
            return true;
        case "dimensions":
            _value = dimensions;
            return true;
        default:
            return base.GetBindingValue(ref _value, _bindingName);
    }
}

To push updated values to the UI:

RefreshBindings(false);  // false = don't force full rebuild

Button Events

// In Init():
XUiController btnClose = GetChildById("btnClose");
if (btnClose != null)
    btnClose.OnPress += OnClosePressed;

// Handler signature:
private void OnClosePressed(XUiController _sender, int _mouseButton)
{
    xui.playerUI.windowManager.Close("myWindowGroup");
}

Finding Child Controllers

// By element name attribute
XUiController child = GetChildById("btnPrint");

// By type (returns array)
XUiC_MyRow[] rows = GetChildrenByType<XUiC_MyRow>(null);

// Walk to parent window
XUiWindow win = GetParentWindow();
XUiC_MyWindow ctrl = win?.Controller as XUiC_MyWindow;

Opening and Closing Windows

Open from a block's OnBlockActivated:

localPlayer.PlayerUI.windowManager.Open("myWindowGroup", true);

Close from within a controller:

xui.playerUI.windowManager.Close("myWindowGroup");

The string passed is the window_group name from xui.xml, not the window name.


Window Group Controller

If your window_group has a controller="..." attribute, that class handles group-level logic. For simple windows this can be a minimal implementation:

public class XUiC_MyWindowGroupController : XUiWindowGroup { }

Showing Toasts / Tooltips

GameManager.ShowTooltip(xui.playerUI.entityPlayer, "Your message here");

Confirmation dialogs (XUiC_MessageBoxWindowGroup)

Vanilla's modal message box works in-game (the eat-item prompt and quest rally confirm use it), not just on the main menu. Verified on 3.0.x:

// (_xui, title, text, icon, onOk, onCancel, _openMainMenuOnClose, _modal, _cancelOnOutsideClick)
XUiC_MessageBoxWindowGroup.ShowOkCancel(xui, "TITLE", "Body text.", "",
    delegate { /* confirmed */ }, null, false);
  • Always pass _openMainMenuOnClose: false in-game — the default true is for main-menu contexts.
  • ShowConfirmCancel(...) is the same but supports a hold-to-confirm time (_confirmHoldTime), used by vanilla for destructive actions like save deletion.
  • The box opens on top of whatever custom window is open and is modal by default; your window stays open behind it.

Creative / debug mode detection

What the cm / dm console commands actually toggle (3.0.x, ConsoleCmdCreativeMenu / ConsoleCmdDebugMenu):

bool cm = GamePrefs.GetBool(EnumGamePrefs.CreativeMenuEnabled);
bool dm = GamePrefs.GetBool(EnumGamePrefs.DebugMenuEnabled);

Use these to gate cheat-tier UI (e.g. the Elevator mod's Excavate/Demolish buttons). They are GamePrefs, not GameStats.


Grid Controllers

The grid controller exposes a public method for the parent window to call after its data is ready (see OnOpen() ordering gotcha below):

public class XUiC_MyGridController : XUiController
{
    public override void OnOpen()
    {
        base.OnOpen();
        // Do NOT populate here — parent data isn't ready yet.
        // Parent window controller calls RefreshGrid() after its own OnOpen().
    }

    public void RefreshGrid()
    {
        // Get parent window controller for data
        XUiC_MyWindow windowCtrl = GetParentWindow()?.Controller
            as XUiC_MyWindow;
        if (windowCtrl == null) return;

        List<string> items = windowCtrl.GetItems();

        XUiController[] children = GetChildrenByType<XUiC_MyRowController>(null);
        for (int i = 0; i < children.Length; i++)
        {
            var row = children[i] as XUiC_MyRowController;
            if (row == null) continue;

            if (i < items.Count)
            {
                row.SetData(i, items[i]);
                row.ViewComponent.IsVisible = true;
            }
            else
            {
                row.SetData(-1, null);
                row.ViewComponent.IsVisible = false;
            }
        }
    }
}

Row (Entry) Controllers

public class XUiC_MyRowController : XUiController
{
    private int index = -1;
    private string displayName = "";

    public override void Init()
    {
        base.Init();
        OnPress += OnRowPressed;
    }

    public void SetData(int idx, string name)
    {
        index = idx;
        displayName = name ?? "";
        RefreshBindings(false);
    }

    // 'new' not 'override' — GetBindingValue is not virtual
    public new bool GetBindingValue(ref string _value, string _bindingName)
    {
        if (_bindingName == "rowText")
        {
            _value = displayName;
            return true;
        }
        return base.GetBindingValue(ref _value, _bindingName);
    }

    private void OnRowPressed(XUiController _sender, int _mouseButton)
    {
        if (index < 0) return;
        // Notify parent window
        var win = GetParentWindow()?.Controller as XUiC_MyWindow;
        win?.SelectItem(index);
    }
}

Recommended Alternative: IMGUI Controllers

Due to persistent text rendering issues in XUi mod windows (labels inside buttons/rects are invisible), Unity IMGUI (MonoBehaviour + OnGUI) is the recommended approach for mod windows with clickable lists or text-heavy UI. See XUi - Window System.md for the full IMGUI pattern and code template.

Key differences from XUi controllers:

  • Extends MonoBehaviour, not XUiController
  • No XML registration — no windows.xml, no xui.xml
  • Open from blocks via MyBrowserIMGUI.Instance.Open() instead of windowManager.Open()
  • Uses singleton pattern with DontDestroyOnLoad
  • Must lock player controls manually (SetControllable(false)) and show cursor
  • Background thread callbacks need MainThreadDispatcher (must be initialized in IModApi.InitMod)

Gotchas & Lessons Learned

GetBindingValue uses new, not override — the base class method is not marked virtual. Attempting override causes CS0506 compile error.

{binding} text does NOT work for mod controllers — because GetBindingValue is not virtual, the XUi framework calls the base class version (through XUiController-typed references), so new method hiding never dispatches to the mod's override. Workaround: set label text directly via XUiV_Label.Text:

XUiController lblCtrl = GetChildById("lblMyLabel");
if (lblCtrl != null)
{
    XUiV_Label label = lblCtrl.ViewComponent as XUiV_Label;
    if (label != null) label.Text = "my value";
}

Use text="" (empty) in the XML instead of text="{binding}". Keep the GetBindingValue method with new as a fallback, but do not rely on it.

Controller names in XML must use "Namespace.ClassName, AssemblyName" format — e.g., controller="SevenAmazon.SevenAmazonShop, 7Amazon". If your controllers are in a namespace, you must include it. If they are NOT in a namespace (global), use controller="ClassName, AssemblyName". The assembly name is the DLL name without .dll. Without the assembly qualifier, the game only searches Assembly-CSharp and won't find mod types.

Mod controller class names must exactly match the controller="" attributecontroller="PixelPasteWindowController" requires a class literally named PixelPasteWindowController. The game internally tries XUiC_PixelPasteWindowController first (prepending XUiC_), then falls back to the bare name. So the bare name (PixelPasteWindowController) is what needs to be in the class definition.

Type.GetType() cannot find mod assembly types — the game's XUi controller resolution (XUiFromXml.setController) calls Type.GetType(name) which only searches Assembly-CSharp and mscorlib — not mod DLLs. The fix is to register an AppDomain.CurrentDomain.AssemblyResolve handler in InitMod so that Type.GetType("ClassName, YourAssemblyName") resolves correctly. The game tries Type.GetType(name + ", Assembly-CSharp") first, then Type.GetType(name) — the AssemblyResolve handler ensures it can find types in your mod DLL. Important: controller class names that start with a digit (e.g. 7AmazonShop) will fail Type.GetType() resolution even with the handler. Use alphanumeric names like SevenAmazonShop instead. Alternative fix: Harmony postfixes on Type.GetType overloads that retry across AppDomain.CurrentDomain.GetAssemblies().

Prefer native XUi windows over IMGUI — always build mod UIs as XUi windows rather than Unity IMGUI (OnGUI). XUi windows get Escape-to-close, cursor management, input blocking, and controller support for free. IMGUI windows bypass the game's input system entirely, making it nearly impossible to prevent Escape from also opening the pause menu.

OnPress fires for any mouse button — check _mouseButton if you need to distinguish left/right click.

GetChildrenByType returns XUiController[] — cast each element individually since the array type is the base class.

GetParentWindow() can return null — always null-check before accessing .Controller.

Setting sprite fill (progress bars)XUiV_Sprite has a Fill property (0.0–1.0) for type="filled" sprites. Access it via GetChildById:

XUiController ctrl = GetChildById("sprFill");
XUiV_Sprite spr = ctrl?.ViewComponent as XUiV_Sprite;
if (spr != null) spr.Fill = 0.75f;

Setting sprite color at runtimeXUiV_Sprite color can be changed via the Color property. Use reflection if the property name varies by game version (Color, color, TintColor).

Moving a view at runtimeXUiView.Position (the pos= attribute) has a public setter that sets positionDirty + SetDirty(), so the transform is reapplied on the next UI update. Works any time after Init, e.g. to make a button track the bottom of a variable-length list:

XUiController btn = GetChildById("btnAddBelow");
if (btn != null) btn.ViewComponent.Position = new Vector2i(16, -(140 + 36 * rowCount));

Coordinates use the same convention as XML pos (y negative going down, relative to the parent).

The [Preserve] attribute — Add [Preserve] (from UnityEngine.Scripting) to any class referenced only via XML controller="" attributes or reflection. Unity's IL stripping may remove classes that aren't directly referenced from code.

OnOpen() fires children-first — child controllers' OnOpen() runs before the parent window's OnOpen(). If a child (e.g., a grid) needs data populated by the parent, do NOT read it in the child's OnOpen(). Instead, have the parent explicitly call a method on the child after preparing the data in its own OnOpen().

iOS-style toggle (slide switch) pattern

Use this when you want a binary on/off control that visibly slides like an iOS toggle. Layout: a button hosts two pairs of sprites (track + knob) that the controller swaps via XUiV_Sprite.IsVisible. The whole row is the button so the click target is generous.

XML

Track is menu_empty3px (rounded-corner sliced rect). Knob is ui_game_filled_circle (truly round). Toggle widget on the LEFT of the label is the conventional layout we use; flip the x-coordinates if you want it on the right.

<button name="myToggle" pos="20,-104" width="240" height="32" depth="3"
        style="press, hover" hoverscale="1.02" sound="[paging_click]">
    <sprite depth="0" name="rowBg" pos="0,0" width="240" height="32"
            color="30,30,40,200" type="sliced" sprite="menu_empty" />
    <!-- Track: rounded pill, 54×24 starting at x=4 -->
    <sprite depth="1" name="trackOff" pos="4,-4" width="54" height="24"
            color="70,70,85,255"   type="sliced" sprite="menu_empty3px" />
    <sprite depth="1" name="trackOn"  pos="4,-4" width="54" height="24"
            color="80,180,100,255" type="sliced" sprite="menu_empty3px" visible="false" />
    <!-- Knob: 20×20 circle. Off-x=6, On-x=36 (24 px slide inside the track). -->
    <sprite depth="2" name="knobOff" pos="6,-6"  width="20" height="20"
            color="240,240,245,255" sprite="ui_game_filled_circle" />
    <sprite depth="2" name="knobOn"  pos="36,-6" width="20" height="20"
            color="255,255,255,255" sprite="ui_game_filled_circle" visible="false" />
    <label name="myLabel" pos="68,-6" width="160" height="22"
           font_size="15" color="240,240,240,255" justify="left"
           text_key="MyToggleLabel" depth="3" />
</button>

Controller

struct Toggle {
    public XUiV_Sprite TrackOff, KnobOff, TrackOn, KnobOn;
    public void Apply(bool on) {
        if (TrackOff != null) TrackOff.IsVisible = !on;
        if (KnobOff  != null) KnobOff.IsVisible  = !on;
        if (TrackOn  != null) TrackOn.IsVisible  = on;
        if (KnobOn   != null) KnobOn.IsVisible   = on;
    }
}

bool _wired;
Toggle _myToggle;

public override void Init() {
    base.Init();
    if (_wired) return;     // see "double-fire" gotcha below
    _wired = true;

    var btn = GetChildById("myToggle");
    if (btn != null) {
        _myToggle.TrackOff = btn.GetChildById("trackOff")?.ViewComponent as XUiV_Sprite;
        _myToggle.TrackOn  = btn.GetChildById("trackOn")?.ViewComponent  as XUiV_Sprite;
        _myToggle.KnobOff  = btn.GetChildById("knobOff")?.ViewComponent  as XUiV_Sprite;
        _myToggle.KnobOn   = btn.GetChildById("knobOn")?.ViewComponent   as XUiV_Sprite;
        btn.OnPress += (_, __) => {
            MyState = !MyState;
            _myToggle.Apply(MyState);
        };
    }
}

public override void OnOpen() {
    base.OnOpen();
    _myToggle.Apply(MyState);   // sync visuals from the source of truth
}

Gotchas

  • Double-fire on click: Init() can run more than once for the same controller (XUi may re-parse the window or re-call Init on first OnOpen). Without a _wired flag every press fires twice (fields toggle, then toggle back, net no change). Symptom in logs: two consecutive [mod] toggle Foo ok=True new=True / new=False pairs ~2 ms apart.
  • OnPress signature is (XUiController _sender, int _mouseButton) — the int is which mouse button (0/1/2), not a press-state boolean. A single click fires it once.
  • Pivot: leaving pivot unset works for these full-row buttons; do not set pivot="topleft" unless your child positions match that origin.
  • Hover scaling: hoverscale="1.0" may make NGUI skip the hover state and break input. Use hoverscale="1.02" (almost imperceptible) when you want minimal hover animation but reliable click handling.
  • Track length vs knob slide: knob On-x = trackPos.x + (trackWidth − knobWidth − inset). For a 54-wide track with 20-wide knob and 6 px inset on each side: knob-off-x=trackX+2, knob-on-x=trackX+32. Adjust if you change track width.
  • Source of truth: keep the toggle state in a static field / model object. The controller only renders. After every press, call Apply() so the visuals match the new value.

⚠️ Don't declare controller= on both the window_group AND the window

A window_group registered in xui.xml and the matching <window> definition in windows.xml will both instantiate their own controller if both carry a controller="..." attribute. The button OnPress events end up wired twice — once per instance — and every click runs the handler twice. The _wired re-entry guard does NOT help here because each duplicate instance has its own _wired field.

Always pick one place. Never put controller="..." in both files for the same window.

Symptom checklist

The bug is not always visible — it depends on what the handler does:

Handler does…What you see
Toggles a bool (MyState = !MyState)Net no-op — flips twice per click. Easy to miss; looks like the button is broken.
Calls windowManager.Open("foo")Hidden — Open is idempotent. Bug stays latent until you add a toggle/counter/spawn.
Spawns/removes/sends an entity or messageVisibly happens twice (two zombies, two chat lines, double damage). The most obvious tell.
Logs from inside the handlerTwo log lines ~1 ms apart per press. Same for OnOpen/Init logs at window open.
Opens an IMGUI tool overlay then Close()s the phone windowPhone window flickers — opens then immediately closes again because the second handler runs on the same click.

If you suspect this bug, add Log.Out("[mod] MyApp Init"); to Init() and open the window once. Two lines = double-instantiated.

Single source of truth

Declare the controller on the window_group only, leave it off the <window> element.

<!-- xui.xml -->
<window_group name="zPhoneBandits" controller="BanditsAppController, zPhone">
    <window name="zPhoneBandits" />
</window_group>

<!-- windows.xml — NO controller= here -->
<window name="zPhoneBandits" width="280" height="900" ...>
    ...
</window>

When adding a new app window, leave a comment above the <window> tag explaining why controller= is intentionally absent — future-you won't remember and may "fix" it back.

Moving a vanilla XUi window at runtime (ViewComponent.Position)

You can reposition any window — including vanilla HUD windows — from C# by setting its view's Position (a Vector2i, integer UI units). No XML patch, no controller subclass. Verified on the 2026-07 build in Uncover (HudStatBarShift.cs), lifting the bottom-right HUD cluster above the minimap:

XUiController win = xui.GetChildById("HUDRightStatBars"); // root XUi.GetChildById searches all groups
XUiView view = win?.ViewComponent;
if (view != null && view.Position.y != targetY)
    view.Position = new Vector2i(view.Position.x, targetY);
  • XUiView.Position set → the transform actually moves. The setter writes position and positionDirty = true; the view's per-frame Update calls TryUpdatePosition() when dirty, so the NGUI transform follows on the next frame. No need to poke isDirty/uiTransform yourself.
  • Offset is from the window's anchor. HUDRightStatBars is anchor="BottomRight" pos="0,0", so +Y = up, +X = right (toward the corner). Raising Y lifts the whole cluster; X stays 0.
  • Units are XUi's 1080p-authored space — the same units used in windows.xml pos= and by IMGUI overlays authored "at 1080p" (Uncover's minimap uses Screen.height/1080 scaling). So a minimap of SizePixels side length is cleared by lifting the bars ~SizePixels + gap in Y directly.
  • Cache the controller, but re-resolve if ViewComponent == null — a HUD rebuild (resolution change, xui reload) re-parses the window and resets Position to the XML value. Re-assert your target only when it drifts (view.Position.y != targetY) so you don't dirty the view every frame and you self-heal after a rebuild.
  • xui here is LocalPlayerUI.GetUIForPrimaryPlayer().xui; drive it from any per-frame loop (Uncover calls it from the minimap MonoBehaviour.Update).

HUDRightStatBars itself is the bottom-right HUD cluster: the held-item ammo/durability bar (stat_type="ActiveItem"), item-pickup toasts (CollectedItemList), and — only while driving — vehicle fuel/health bars. All three are conditionally visible, so an empty-handed player on foot sees nothing there; pull out a gun to make the ammo bar appear and test overlap.

Interaction prompt override (XUiC_InteractionPrompt)

To show the vanilla "Press ( E ) to ..." HUD prompt for a custom interaction (verified in minecarts EntityMinecart.UpdateInteractionPromptOverride and zPhone GodLightSwitch):

string binding = player.playerInput.Activate.GetBindingXuiMarkupString()
               + player.playerInput.PermanentActions.Activate.GetBindingXuiMarkupString();
XUiC_InteractionPrompt.SetText(ui, "Press ( " + binding + " ) to interact with light");
  • SetText(ui, text) opens the prompt window (non-modal); SetText(ui, "") (or null) closes it. Nothing auto-clears it — if you stop calling SetText while your text is showing, it sticks on screen.
  • Re-apply every frame from LateUpdate so your override lands after vanilla's own prompt writes for the frame.
  • On losing focus, clear only if you still own the prompt, so you don't stomp a prompt a vanilla interactable took over this frame:
    var prompt = ui.xui.FindWindowGroupByName(XUiC_InteractionPrompt.ID)
        ?.GetChildByType<XUiC_InteractionPrompt>();
    if (prompt != null && prompt.Text == myLastText)
        XUiC_InteractionPrompt.SetText(ui, "");
    
    (Text's getter is [PublicizedFrom] → public in the shipped assembly.)
  • player.playerInput.Activate is a PlayerAction from InControl — the csproj needs a reference to Managed\InControl.dll or you get CS0012 "type 'PlayerAction' is defined in an assembly that is not referenced".