XUi - Controllers (C#)
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: falsein-game — the defaulttrueis 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, notXUiController - No XML registration — no
windows.xml, noxui.xml - Open from blocks via
MyBrowserIMGUI.Instance.Open()instead ofwindowManager.Open() - Uses singleton pattern with
DontDestroyOnLoad - Must lock player controls manually (
SetControllable(false)) and show cursor - Background thread callbacks need
MainThreadDispatcher(must be initialized inIModApi.InitMod)
Gotchas & Lessons Learned
GetBindingValueusesnew, notoverride— the base class method is not markedvirtual. Attemptingoverridecauses CS0506 compile error.
{binding}text does NOT work for mod controllers — becauseGetBindingValueis not virtual, the XUi framework calls the base class version (throughXUiController-typed references), sonewmethod hiding never dispatches to the mod's override. Workaround: set label text directly viaXUiV_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 oftext="{binding}". Keep theGetBindingValuemethod withnewas 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), usecontroller="ClassName, AssemblyName". The assembly name is the DLL name without.dll. Without the assembly qualifier, the game only searchesAssembly-CSharpand won't find mod types.Mod controller class names must exactly match the
controller=""attribute —controller="PixelPasteWindowController"requires a class literally namedPixelPasteWindowController. The game internally triesXUiC_PixelPasteWindowControllerfirst (prependingXUiC_), 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) callsType.GetType(name)which only searchesAssembly-CSharpand mscorlib — not mod DLLs. The fix is to register anAppDomain.CurrentDomain.AssemblyResolvehandler inInitModso thatType.GetType("ClassName, YourAssemblyName")resolves correctly. The game triesType.GetType(name + ", Assembly-CSharp")first, thenType.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 failType.GetType()resolution even with the handler. Use alphanumeric names likeSevenAmazonShopinstead. Alternative fix: Harmony postfixes onType.GetTypeoverloads that retry acrossAppDomain.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.
OnPressfires for any mouse button — check_mouseButtonif you need to distinguish left/right click.
GetChildrenByTypereturnsXUiController[]— 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_Spritehas aFillproperty (0.0–1.0) fortype="filled"sprites. Access it viaGetChildById:XUiController ctrl = GetChildById("sprFill"); XUiV_Sprite spr = ctrl?.ViewComponent as XUiV_Sprite; if (spr != null) spr.Fill = 0.75f;
Setting sprite color at runtime —
XUiV_Spritecolor can be changed via theColorproperty. Use reflection if the property name varies by game version (Color,color,TintColor).
Moving a view at runtime —
XUiView.Position(thepos=attribute) has a public setter that setspositionDirty+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](fromUnityEngine.Scripting) to any class referenced only via XMLcontroller=""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'sOnOpen(). If a child (e.g., a grid) needs data populated by the parent, do NOT read it in the child'sOnOpen(). Instead, have the parent explicitly call a method on the child after preparing the data in its ownOnOpen().
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_wiredflag 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=Falsepairs ~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
pivotunset works for these full-row buttons; do not setpivot="topleft"unless your child positions match that origin. - Hover scaling:
hoverscale="1.0"may make NGUI skip the hover state and break input. Usehoverscale="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 message | Visibly happens twice (two zombies, two chat lines, double damage). The most obvious tell. |
| Logs from inside the handler | Two 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 window | Phone 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.Positionset → the transform actually moves. The setter writespositionandpositionDirty = true; the view's per-frameUpdatecallsTryUpdatePosition()when dirty, so the NGUI transform follows on the next frame. No need to pokeisDirty/uiTransformyourself.- Offset is from the window's
anchor.HUDRightStatBarsisanchor="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.xmlpos=and by IMGUI overlays authored "at 1080p" (Uncover's minimap usesScreen.height/1080scaling). So a minimap ofSizePixelsside length is cleared by lifting the bars ~SizePixels + gapin Y directly. - Cache the controller, but re-resolve if
ViewComponent == null— a HUD rebuild (resolution change,xui reload) re-parses the window and resetsPositionto 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. xuihere isLocalPlayerUI.GetUIForPrimaryPlayer().xui; drive it from any per-frame loop (Uncover calls it from the minimapMonoBehaviour.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.Activateis aPlayerActionfrom InControl — the csproj needs a reference toManaged\InControl.dllor you get CS0012 "type 'PlayerAction' is defined in an assembly that is not referenced".