UI & Online · Updated 2552.08.07.08.17
UI System — CommonUI + Menus (Design Doc)
Reference doc for the upcoming menu / settings / pause UI work. Captures the current state of the CommonUI scaffolding, the base classes still to author, the controller-input pipel
Reference doc for the upcoming menu / settings / pause UI work. Captures the current state of the CommonUI scaffolding, the base classes still to author, the controller-input pipeline (the part most likely to bite us), and a long list of UE 5.7 footguns to watch for.
When implementation lands, sections of this doc move to Docs/UISystem.md "How it works" and the build-order checklist gets folded into Docs/Progress.md.
#1. Goal
A full menu / HUD UI system built on CommonUI that:
- Works identically on mouse+keyboard and gamepad (Xbox / DS4 / DS5) — **no UX regressions on
controller**. Directional D-pad / left-stick navigation between buttons, A/X to confirm, B/O to back out, LB/RB to switch tabs, Start to pause, etc.
- Has a clean layer model — HUD always-on, in-game stacks (objective popups), menu stack
(pause / settings), modal stack (confirmations) — already partly in place via USLPrimaryGameLayout.
- Provides base classes per widget category (screen, button, list entry, modal, tab list, settings
row) so each new BP only authors layout + visuals, never plumbing.
- Pauses / unpauses input correctly: gameplay input blocked while in menus, UI input blocked while
in game, focus restored to the right place on close.
- Future-proofs for: split-screen, settings UI, controller remapping, accessibility.
Out of scope for this pass: localization, save/load of settings, MVVM (ModelViewViewModel). Those layer on top of the foundation.
#2. Current State (as of 2026-05-22)
Update 2026-07-06 (branch menu-foundation): the §4.3 base-class kit is built, and the following items in
"What's missing" below are now DONE: pop/back API onUSLPrimaryGameLayout(PopWidgetFromLayer/ClearLayer/
GetActiveWidget), input-mode switching (GetDesiredInputConfigon the activatable), input-method-change focus
restore, and pause integration (bPauseGameWhileActiveon the activatable +OpenPauseMenu/ClosePauseMenu/
TogglePauseMenuonASLPlayerController).bEnableEnhancedInputSupport=Trueis set inDefaultGame.ini.
Still missing (Phase 0b): the UI input actions +IMC_SL_UI, CommonUI data tables/UCommonUIInputData, and
the CD_SL_* controller glyph data.
#What's in place
| Layer | File | Status |
|---|---|---|
| Layout root | Public/UI/SLPrimaryGameLayout.h/.cpp | ✅ four layers (HUD Overlay + Game/Menu/Modal stacks) wired via meta=(BindWidget). PushWidgetToLayer + GetForPlayer helpers exist. |
| HUD bootstrap | Public/UI/SLPlayerHUD.h/.cpp | ✅ creates the layout + HUD widget in BeginPlay; exposes PushToGameStack/PushToMenuStack/PushToModalStack |
| HUD widget | Public/UI/SLHUDWidget.h/.cpp | ✅ persistent overlay — reticle, ammo, health, damage, notifications |
| Activatable base | Public/UI/SLCommonActivatableWidget.h/.cpp | ⚠️ exists but minimal — has AutoFocusWidget + bAutoFocusOnActivate, no input-method-change handling, no GetDesiredFocusTarget override |
| Viewport client | Public/UI/SLGameViewportClient.h/.cpp | ✅ inherits UCommonGameViewportClient; set in DefaultEngine.ini as GameViewportClientClassName |
| Layer enum | Public/Types/ESLUILayer.h | ✅ HUD / Game / Menu / Modal |
| Build deps | SystemLinkCore.Build.cs | ✅ CommonUI, CommonInput, EnhancedInput, UMG, Slate, SlateCore linked |
| Plugin | SystemLink.uproject | ✅ CommonUI enabled |
| Game default | Config/DefaultGame.ini | ⚠️ Only CommonButtonAcceptKeyHandling=TriggerClick set. No CommonInputSettings.InputData yet — that's required for action bar / input icons. |
#What's missing
- No UI input actions at all (
Content/SystemLink/Inputs/Actions/) — gameplay-only set: PrimaryFire, Move, Look, Jump, Crouch, Interact, DropWeapon, CycleWeapon, ToggleViewMode. Need at minimum:IA_UI_Confirm,IA_UI_Back,IA_UI_Navigate(2D),IA_UI_NextTab,IA_UI_PrevTab,IA_UI_Pause. Note: CommonUI's built-inFBindUIActionArgsuses its ownUCommonInputActionDataBasedata tables, NOT raw EnhancedInputUInputActions — see §4.2.
- No
UCommonInputActionDataBasedata tables orUCommonUIInputDatadata asset configured. Without these, theCommonActionWidgetbutton-prompt icons inCommonBoundActionBarshow as[NONE].
- No controller data assets (
UCommonInputBaseControllerDatasubclasses) — these map an input type (Xbox / PS5 / Switch / KBM) to a sprite atlas for prompts.
- No button base class (
USLButtonBase : UCommonButtonBase). Each menu BP currently uses rawUButton, which does not work with controller focus navigation.
- No pop / back logic in
USLPrimaryGameLayout— onlyPushWidgetToLayer. CommonUI handles back viaCancelAction, but theUSLPrimaryGameLayoutshould expose a wrapper for explicit BP pops.
- No input mode switching when a menu opens —
ASLPlayerControlleris permanently inGameOnlymode. Opening a menu doesn't change focus / show cursor / freeze gameplay input.
- No pause integration —
UGameplayStatics::SetGamePausednot called anywhere.
- No tab list base for tabbed settings screens.
- No list entry base for option rows (label + control).
- No modal/confirm widget for "Quit to main menu — are you sure?" flow.
USLCommonActivatableWidgetdoesn't subscribe toUCommonInputSubsystem::OnInputMethodChangedNative— gamepad ↔ KBM transitions don't trigger focus restore (the older reference project atC:\3D-DEV\HaloProject\SystemLinkdoes this and it's worth lifting wholesale).
#What can be borrowed from the old project
C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\ has working classes we can adapt rather than write from scratch:
| Old class | What to take | Notes |
|---|---|---|
SystemLinkActivatableWidget | The HandleInputMethodChanged + TryRestoreFocusIfNeeded + FocusDefaultTargetIfPossible pattern | Subscribes to OnInputMethodChangedNative, restores focus on gamepad, releases on KBM. Has a nice IsAnyTextEntryActive() guard to not steal focus while typing. Inline as enhancements to USLCommonActivatableWidget. |
SystemLinkButtonBase | Text styling + description text + uppercase toggle | Straight subclass of UCommonButtonBase. |
SystemLinkConfirmModal | Ok / YesNo / OkCancel modal pattern with UCommonBoundActionBar | The action bar lights up controller prompts at the bottom of the modal. |
SystemLinkTabListWidgetBase | Tab list with input switching | Inherits UCommonTabListWidgetBase. |
Content/Blueprints/UI/Input/Actions/* + CUI_SystemLinkInputActionDataTable | The CommonUI input data table schema | Don't copy assets across projects (UE 5.4 → 5.7 redirector risk) — re-author in 5.7 with the same names. |
⚠️ The old project is on an earlier UE version and uses bEnableEnhancedInputSupport=False — we want this True so CommonUI uses Enhanced Input under the hood. See §4.2.
#3. Architecture
#Layer model (already in place)
ASLPlayerHUD
└── USLPrimaryGameLayout (root widget)
├── HUDLayer UOverlay — persistent, no stacking
├── GameStack UCommonActivatableWidgetStack — in-game popups
├── MenuStack UCommonActivatableWidgetStack — pause, settings, inventory
└── ModalStack UCommonActivatableWidgetStack — confirm dialogs, top of z-order
A widget added to a stack via PushWidget is activated; when it deactivates (or is removed), the stack auto-activates the next widget down. Each stack manages its own focus / input.
#HUD children: container-and-class vs designer-placed (decided 2026-08-06)
USLHUDWidget hosts its children two different ways. This looks arbitrary but is not, and the split should be preserved rather than unified:
**Use a bound container + a TSubclassOf property, created at runtime, when the widget's CLASS varies
at runtime or the widget is TRANSIENT.**
**Use a designer-placedBindWidget/BindWidgetOptionalwhen there is exactly one, it is always
present, and it is never swapped.**
| Child | Pattern | Why |
|---|---|---|
| Reticle | ReticleContainer + per-weapon class | Class varies per weapon; instances are cached and reused |
| Notifications / pickup prompt | NotificationContainer + class | Several classes, and transient by nature |
| Sidearm ammo | SidearmAmmoContainer + WeaponData->AmmoWidgetClass | Class comes from the equipped sidearm's data asset |
| Health widget | HealthContainer + HealthWidgetClass | Does not need it — see note below |
| Damage overlay | DamageOverlayContainer + class | Does not need it — see note below |
AmmoStrip | designer, BindWidget | One class, always present |
GrenadeIndicator | designer, BindWidgetOptional | One class, optional per HUD |
Health and damage overlay are the two that do not strictly need the indirection — one class each, always present, never swapped. They were deliberately left as-is (2026-08-06): converting them would buy designer-visible layout and a compile-time bind error instead of an unset TSubclassOf failing silently, but it would cost a HUD Blueprint subclass the ability to point at a different health widget without rebuilding the layout. That configurability matters for MenuKit as a Fab product, where a licensee swapping in their own widget is a feature, and WB_DamageHudOld shows the swap has been used before. Not worth refactoring working code either direction — but new children should follow the rule above rather than copying whichever neighbour they happen to sit next to.
⚠ BindWidgetOptional binds null on a name mismatch with no warning (§6.1). Plain BindWidget fails the Blueprint compile instead, so prefer it whenever the child is genuinely mandatory.
#Widget class hierarchy (target end state)
UCommonActivatableWidget
└── USLCommonActivatableWidget <-- exists, needs enhancements (§4.1)
├── USLScreenWidget <-- new: full-screen menus (Pause, Settings, MainMenu)
├── USLModalWidget <-- new: confirm dialogs with action bar
├── USLTabbedScreenWidget <-- new: ScreenWidget + UCommonTabListWidgetBase
└── USLGameOverlayWidget <-- new: GameStack popups (objective hints, pickup prompts that need to pause input)
UCommonButtonBase
└── USLButtonBase <-- new: project-wide button base
UCommonTabListWidgetBase
└── USLTabListWidget <-- new: ties tab buttons to UCommonActivatableWidgetSwitcher
UUserWidget (or UCommonUserWidget — see §6.4)
├── USLListEntryWidget <-- new: row in a settings list (Label + Control + Description)
├── USLSettingsRow_Toggle <-- subclass for boolean settings
├── USLSettingsRow_Slider <-- subclass for numeric settings
├── USLSettingsRow_Dropdown <-- subclass for enum settings
└── USLSettingsRow_KeyBind <-- subclass for input remapping
#Push / pop flow (target)
Player presses Pause (gamepad: Start)
→ ASLPlayerController binds IA_UI_Pause → CallFunction OpenPauseMenu
→ ASLPlayerHUD::PushToMenuStack(WBP_SL_PauseMenu)
└── PrimaryGameLayout->PushWidgetToLayer(Menu, PauseMenuClass)
└── MenuStack adds widget, calls NativeOnActivated
├── SetInputMode_GameAndUI + SetShowMouseCursor(true) on PC
├── UGameplayStatics::SetGamePaused(true)
├── AutoFocusWidget receives user focus (controller-friendly)
└── Subscribes to OnInputMethodChangedNative
→ Player presses B (gamepad) or Esc (KBM)
→ CommonUI built-in CancelAction fires
→ Top widget on stack deactivates + removes itself
→ NativeOnDeactivated:
├── If MenuStack now empty: SetInputMode_GameOnly + cursor hide + unpause
├── Else: focus restored to next-down widget's AutoFocus
└── Unsubscribe input change handler
#4. Required Foundation Work
#4.1 Enhance USLCommonActivatableWidget
Currently only has AutoFocusWidget + bAutoFocusOnActivate. Bring it up to parity with the reference project plus a couple of cleanups:
// New API on USLCommonActivatableWidget
protected:
// CommonUI back-action — set in BP defaults; default-bind to CancelAction.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bSupportsCancelAction = true;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bAutoRestoreFocusOnGamepad = true;
// Pause game while this widget is active (use for pause menu, not for HUD popups).
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
bool bPauseGameWhileActive = false;
// Input mode to apply on activation. Restored on deactivation if stack is empty.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="SystemLink|UI")
ESLUIInputMode InputModeOnActivate = ESLUIInputMode::GameAndUI;
virtual UWidget* NativeGetDesiredFocusTarget() const override; // returns AutoFocusWidget
virtual TOptional<FUIInputConfig> GetDesiredInputConfig() const override; // standard CommonUI input config hook
// Bind/unbind OnInputMethodChangedNative; restore focus on gamepad transition.
virtual void NativeOnActivated() override;
virtual void NativeOnDeactivated() override;
private:
UFUNCTION()
void HandleInputMethodChanged(ECommonInputType NewType);
void TryRestoreFocusIfNeeded() const;
Key: the GetDesiredInputConfig() override is how CommonUI knows to flip input mode on activation. It returns an FUIInputConfig with InputMode, MouseCaptureMode, bHideCursorDuringViewportCapture. CommonUI applies it automatically — don't call SetInputMode_* from this class directly.
Also required — deferred re-focus after transitions (fixes footgun 6.28b): activation-time focus fails if the widget is mid-fade. So USLCommonActivatableWidget must re-assert focus once the screen is settled, not only in NativeOnActivated. Add a ReassertFocusIfNeeded() that runs deferred (next tick via SetTimerForNextTick, or RequestRefreshFocus() on CommonUI 5.x) and, if no descendant holds focus and the current input is gamepad, calls SetUserFocus(NativeGetDesiredFocusTarget()). Call it from NativeOnActivated (deferred) and reuse it from HandleInputMethodChanged. Pair with the authoring rule: fades animate Render Opacity, not Visibility. This is what makes fade/tab/modal transitions reliable on a controller across every screen.
#4.2 EnhancedInput + CommonUI integration (the controller-input pipeline)
This is the part most people get wrong, so spell it out:
CommonInputSettings (Project Settings → Engine → Common Input Settings) — required entries:
; Config/DefaultGame.ini
[/Script/CommonInput.CommonInputSettings]
InputData=/Game/SystemLink/UI/Input/DA_SL_CommonInputData.DA_SL_CommonInputData_C
bEnableEnhancedInputSupport=True
DefaultInputType=MouseAndKeyboard
DefaultGamepadName=Generic
+PlatformInputs=(PlatformName="Windows", DefaultInputType=MouseAndKeyboard, ...)
InputDatapoints to a Blueprint asset deriving fromUCommonUIInputData. That class
exposes two FDataTableRowHandle slots: DefaultClickAction and DefaultBackAction. Both should point to rows in a UCommonInputActionDataBase data table (next bullet).
bEnableEnhancedInputSupport=Trueis critical — it tells CommonUI to route through
UEnhancedInputLocalPlayerSubsystem instead of legacy input. Without it, FBindUIActionArgs built from UInputAction will be ignored.
Input data tables — two assets to author:
DT_SL_InputActions—UDataTableof row typeCommonInputActionDataBase. One row per- DisplayName ("Accept", "Back", "Pause") — shown in action bar
- KeyboardInputTypeInfo / GamepadInputTypeInfo / TouchInputTypeInfo — per-platform key + icon override
UI action (Confirm, Back, Navigate, NextTab, PrevTab, Pause, Inspect, Reset). Each row carries:
DA_SL_CommonInputData—UCommonUIInputDataBlueprint asset. Set:- DefaultClickAction → row handle pointing at
DT_SL_InputActions::Confirm - DefaultBackAction → row handle pointing at
DT_SL_InputActions::Back - EnhancedInputClickAction / EnhancedInputBackAction → the actual
UInputAction(IA_UI_Confirm/IA_UI_Back)
Controller data assets — per platform:
For each input type (Xbox, PS5, Generic Gamepad, KBM), author a UCommonInputBaseControllerData Blueprint asset that maps FKey → sprite from the prompt icon atlas. Register them under:
[/Script/CommonInput.CommonInputSettings]
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_Xbox.CD_SL_Xbox_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_PS5.CD_SL_PS5_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_Generic.CD_SL_Generic_C
+ControllerData=/Game/SystemLink/UI/Input/Controllers/CD_SL_KBM.CD_SL_KBM_C
Enhanced Input — UI mapping context:
Author IMC_SL_UI separate from the gameplay IMC_Default. Map:
| Action | KBM key | Xbox | PS |
|---|---|---|---|
IA_UI_Confirm | Enter / Space | A | Cross |
IA_UI_Back | Esc | B | Circle |
IA_UI_Navigate (Vector2D) | WASD / Arrows | LeftStick + DPad | LeftStick + DPad |
IA_UI_NextTab | E | RB | R1 |
IA_UI_PrevTab | Q | LB | L1 |
IA_UI_Pause | Esc / P | Start | Options |
IMC_SL_UI must be applied while a menu is open — and MUST NOT be applied outside one. Both halves bite, in opposite directions, and each one cost a session (2026-07-15):
- Applied only while a menu owns input — because CommonUI resolves every glyph through
QueryKeysMappedToAction, which reports only contexts applied right now. No live context → no key → NoBrush → the action widget collapses itself. See §6.32.
- Never applied during play — the UI actions share keys with gameplay (
Confirm=A/Space vsJump=A/Space;
NextTab=E/RB vs Interact=E/RB; PrevTab=Q/LB vs SidearmMode/Grenade) and UInputAction defaults to bConsumeInput=True. Applied at a higher priority during play, the UI actions silently eat those gameplay actions — jump/interact/grenade just stop working, with nothing in the log. See §6.33.
Where it's wired (as built): ASLPlayerController::UIInputMappingContext, added/removed inside Push/PopGameplayInputSuppression — the ref-counted "a menu owns input" signal that already swaps the gameplay contexts out. Gameplay contexts off ⇄ UI context on, one place, every close path, ref-counted for stacked menus. It is deliberately not a member of InputMappingContexts: that array is the set suppression removes, and this context takes the opposite path.
Known limit: glyphs can only resolve while a menu is open. That's fine for menus, but a HUD prompt
outside a menu ("Press [A] to pick up") will render blank. When that's needed, don't just leave the context
applied — setbConsumeInput=Falseon theIA_SL_UI_*actions first, or the collisions above come back.
#4.3 New base classes to author
Put all of these in Plugins/SystemLinkCore/Source/SystemLinkCore/Public/UI/ per the existing folder structure. Each one needs a doc comment matching the project pattern.
✅ BUILT & build-verified (2026-06-19, branch grenade-initial-imp). The sketches below are the
original plan; the shipped classes match them in spirit with these deltas worth knowing when authoring BPs:
- USLButtonBase — label text + uppercase toggle + text-style sync; description surfaces on focus
and hover via OnDescriptionChanged(FText) BIE (controller-first, footgun 6.19). Label block is
ButtonText(BindWidgetOptional,UCommonTextBlock).
-USLScreenWidget— constructor setsInputModeOnActivate = MenuandbIsBackHandler = true. Focus
is handled by the baseUSLCommonActivatableWidget(deferred re-focus, footgun 6.28b); nobAutoActivate
override (let the stack drive activation, footgun 6.10).
-USLCommonActivatableWidget— now also carriesInputModeOnActivate(ESLUIInputMode::GameAndUI/Menu)
and implementsGetDesiredInputConfig()→FUIInputConfigwithMouseCaptureMode::NoCapture(cursor stays
free for KBM while the gamepad drives focus). See §4.1.
-USLModalWidget— shipped as a binary confirm/cancel dialog (ESLModalResult { Confirmed, Cancelled }),
not the Ok/YesNo/etc. matrix. `static PushModal(APlayerController*, TSubclassOf<USLModalWidget>, FText Title,
FText Message)` pushes to the Modal layer and returns the instance; bind the BlueprintAssignable
OnModalResulton it. Buttons areConfirmButton/CancelButton(BindWidgetOptional,USLButtonBase),
wired via the public OnClicked() native event; back/Esc/B = Cancelled; result is one-shot and the modal
self-removes. Default controller focus lands on Confirm (falls back to Cancel). A multi-button variant can
be layered later if a screen needs it.
-USLTabListWidget—DefaultTabButtonClass+RegisterTabWithLabel(TabId, Label, Content, Index=-1)
(labels the createdUSLButtonBaseviaHandleTabCreation); auto-selects the first tab. Engine drives LB/RB
cycling + the linked switcher — callSetLinkedSwitcher()thenRegisterTabWithLabel()per panel from the
owning Settings screen.
-USLListEntryWidget—UCommonUserWidget+IUserObjectListEntryrow base for virtualized lists
(server browser / scoreboard / lobby roster). Caches the item + selection (GetEntryItem(),
IsEntrySelected()) and adds C++NativeOnItemSet(UObject*)/NativeOnSelectionChanged(bool)hooks on top
of the inherited BP events (On List Item Object Set,On Item Selection Changed). Host in a
UCommonListView, never a hand-filled box (footgun 6.8).
-USLSettingsRowWidget+ rows — shared base owns label (LabelTextbind) +Description(shown on focus
by the owning screen). C++ owns the value + change broadcast; the BP wires the actual control and reflects
state via the On*Refreshed BIE hooks:
-USLToggleRow—bool;SetValue/ToggleValue,OnValueChanged(bool).
-USLSliderRow—float+MinValue/MaxValue/StepSize;SetValue/SetValueFromNormalized,
GetNormalizedValue()for the slider widget,OnValueChanged(float).
-USLDropdownRow— option list + index; rotator-friendlySelectNext/SelectPrevious(footgun 6.14),
OnSelectionChanged(int32, FText).
Still to author: USLKeyBindRow — deferred to the input-settings phase (needs the Enhanced Input
PlayerMappableKeySettings rebind pipeline, not a stub).
#USLButtonBase : UCommonButtonBase
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLButtonBase : public UCommonButtonBase
{
GENERATED_BODY()
protected:
// Optional description text shown below button (e.g. tooltip on hover/focus).
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI")
FText DescriptionText;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI")
FText ButtonText;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="SystemLink|UI",
meta=(InlineEditConditionToggle))
bool bUppercase = false;
virtual void NativePreConstruct() override;
// BP designer can override to apply ButtonText/DescriptionText to widget bindings
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category="SystemLink|UI")
void RefreshButtonText();
};
#USLScreenWidget : USLCommonActivatableWidget
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLScreenWidget : public USLCommonActivatableWidget
{
GENERATED_BODY()
public:
USLScreenWidget();
protected:
// Defaults appropriate for full-screen menus.
// Subclass CDOs can override these in BP defaults.
// Set in constructor:
// bIsBackHandler = true
// bAutoActivate = false
// bSupportsActivationFocus = true
// InputModeOnActivate = GameAndUI
};
Constructor sets:
USLScreenWidget::USLScreenWidget()
{
bIsBackHandler = true;
bAutoActivate = false;
SetIsFocusable(true);
bSupportsActivationFocus = true;
}
#USLModalWidget : USLCommonActivatableWidget
UENUM(BlueprintType)
enum class ESLModalButtons : uint8 { Ok, OkCancel, YesNo, YesNoCancel };
DECLARE_DYNAMIC_DELEGATE_OneParam(FSLOnModalResult, ESLModalResult, Result);
UCLASS(Abstract)
class SYSTEMLINKCORE_API USLModalWidget : public USLCommonActivatableWidget
{
GENERATED_BODY()
public:
static USLModalWidget* PushModal(
const UObject* WorldContext,
TSubclassOf<USLModalWidget> ModalClass,
const FText& Title,
const FText& Body,
ESLModalButtons Buttons,
FSLOnModalResult OnResult);
protected:
// Three CommonButtonBase BindWidgets — populated in BP. The C++ side wires their OnClicked.
UPROPERTY(meta=(BindWidget)) TObjectPtr<USLButtonBase> ConfirmButton;
UPROPERTY(meta=(BindWidget)) TObjectPtr<USLButtonBase> CancelButton;
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<USLButtonBase> AlternateButton;
UPROPERTY(meta=(BindWidget)) TObjectPtr<UTextBlock> TitleText;
UPROPERTY(meta=(BindWidget)) TObjectPtr<UTextBlock> BodyText;
// Optional UCommonBoundActionBar bind — auto-fills with Confirm/Back prompts.
UPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<UCommonBoundActionBar> ActionBar;
};
#USLTabListWidget : UCommonTabListWidgetBase
Wraps a UCommonAnimatedSwitcher (or UCommonActivatableWidgetSwitcher) of tab content widgets, plus a horizontal box of USLButtonBase tab buttons. CommonUI handles the LB/RB bindings to cycle tabs natively — just need to expose RegisterTab(TabId, ButtonClass, ContentWidget).
#USLListEntryWidget : UUserWidget
Base row for option lists (settings page). Pairs:
- Label (FText)
- Control area (variable widget — toggle, slider, dropdown, key bind)
- Description (FText, shown on focus)
Subclasses:
USLSettingsRow_Toggle—UCheckBoxor custom CommonButton toggle
USLSettingsRow_Slider—USlider+ value label
USLSettingsRow_Dropdown—UCommonRotator(CommonUI's built-in left/right cycler — perfect for gamepad)
USLSettingsRow_KeyBind— display current binding, "Press a key…" mode for rebinding
⚠️ For lists with many entries, do NOT manually populate a UVerticalBox. Use UCommonListView (IUserObjectListEntry interface) so entries virtualize. See §6.8.
#4.4 Pop / back wiring on USLPrimaryGameLayout
Add to USLPrimaryGameLayout:
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void PopWidgetFromLayer(ESLUILayer Layer);
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void ClearLayer(ESLUILayer Layer);
UFUNCTION(BlueprintPure, Category="SystemLink|UI")
USLCommonActivatableWidget* GetActiveWidget(ESLUILayer Layer) const;
UCommonActivatableWidgetStack::RemoveWidget(Top) does the work. The stack auto-activates the next widget down. Pop helpers are wrappers around this.
#4.5 Pause input + game flow
Add a method to ASLPlayerController that menus call (or wire via GetDesiredInputConfig in USLCommonActivatableWidget — which handles the input mode side):
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void OpenPauseMenu();
UFUNCTION(BlueprintCallable, Category="SystemLink|UI")
void ClosePauseMenu();
OpenPauseMenu:
- Reject if not local controller or character dead.
HUD->PushToMenuStack(PauseMenuClass)— input mode + cursor handled by activatable widget's
GetDesiredInputConfig.
UGameplayStatics::SetGamePaused(this, true)— but only on standalone / listen-server host.
In multiplayer dedicated, never pause the world; the pause menu UI shows but the game keeps running. Standard behavior — SetGamePaused is a no-op in netmode authority on dedicated.
IA_UI_Pause is bound at the PC level in SetupInputComponent and calls OpenPauseMenu. It must be in the gameplay IMC, not the UI IMC — when the menu is open, Esc/B becomes the back action (handled by CommonUI's CancelAction), and re-pressing Pause from inside a menu is unusual.
#5. Controller Input — The Important Stuff
CommonUI's controller support is excellent if you set it up correctly and a black hole of "why does my button not navigate" if you don't.
#5.1 Focus rules
- **Only widgets that derive from
SUserWidget-aware bases (CommonButtonBase, etc.) participate
in focus navigation**. Raw UButton, UTextBlock, UImage do not. If you want a clickable in a menu, it must be a USLButtonBase.
- A widget must have
bIsFocusable = trueANDVisibility != Collapsed/Hiddento receive focus.
- Direction navigation between buttons follows their on-screen layout in the slot panel —
CommonUI walks the Slate widget tree spatially. If buttons are stacked in a UVerticalBox, Up/Down works naturally. Horizontal UHorizontalBox gives Left/Right. Grids work too.
- Override
NativeGetDesiredFocusTarget()on every activatable widget. Return the widget
that should receive focus on activation. If you don't, focus falls to the first focusable child found by Slate's walk, which on a complex layout might be a tab button you didn't want.
#5.2 The OnInputMethodChangedNative dance
Player on gamepad → opens menu → focus goes to AutoFocusWidget → player clicks mouse → focus clears, mouse cursor appears → player picks up controller again → focus must be restored or the menu is unnavigable.
Lifted from the reference project (SystemLinkActivatableWidget.cpp:119-126):
void USLCommonActivatableWidget::NativeOnActivated()
{
Super::NativeOnActivated();
if (ULocalPlayer* LP = GetOwningLocalPlayer())
{
if (UCommonInputSubsystem* CIS = UCommonInputSubsystem::Get(LP))
{
CIS->OnInputMethodChangedNative.AddUObject(this, &ThisClass::HandleInputMethodChanged);
HandleInputMethodChanged(CIS->GetCurrentInputType());
}
}
}
void USLCommonActivatableWidget::HandleInputMethodChanged(ECommonInputType NewType)
{
if (bAutoRestoreFocusOnGamepad && NewType == ECommonInputType::Gamepad)
{
TryRestoreFocusIfNeeded();
}
}
TryRestoreFocusIfNeeded should:
- Skip if input type isn't gamepad
- Skip if a descendant already has focus (
HasFocusedDescendants())
- Skip if a text-entry field is focused (don't steal focus while user is typing)
- Skip if we aren't the active widget on the stack
- Otherwise:
NativeGetDesiredFocusTarget()->SetUserFocus(PC)
#5.3 Action bar
UCommonBoundActionBar is the strip at the bottom of a menu that shows "A Accept | B Back |
|---|
Y Reset" with platform-appropriate icons. It picks these up from:
- The active widget's
RegisterBindingcalls (action ID + handler)
- Plus the global Confirm / Back actions from
UCommonUIInputData
To add an action to a screen:
FBindUIActionArgs Args(SLTags::UI::Actions::Reset, false, FSimpleDelegate::CreateUObject(this, &ThisClass::HandleReset));
Args.bDisplayInActionBar = true;
Args.OverrideDisplayName = NSLOCTEXT("Menus", "Reset", "Reset to defaults");
RegisterBinding(Args);
⚠️ Corrected 2026-07-15 — an earlier version of this section claimed the bar must be in the same widget tree. It doesn't. UCommonBoundActionBar::UpdateDisplay() gathers bindings from the local player's UCommonUIActionRouterBase subsystem, not from its own tree, and shows whatever the active input path has registered. So one bar in a shared layout serves every screen — you don't need one per screen (and two visible bars would both show the same actions).
What the bar actually requires:
| Requirement | Where | Bites if missing |
|---|---|---|
ActionButtonClass set on the bar | bar instance | Compile error (self-enforcing — the one safe link) |
Entry widget has InputActionWidget and Text_ActionName | WBP_SL_BoundActionButton | See §6.35 — blank button, no text either |
bDisplayInActionBar = true on the binding | FBindUIActionArgs | Action works, never displays |
bIsBackActionDisplayedInActionBar = true for the back action | activatable widget — defaults false | B/Esc works but never appears (§6.35) |
A display name (OverrideDisplayName → else InputAction->ActionDescription) | binding / the UInputAction | Correct glyph, blank label |
| The UI IMC applied | ASLPlayerController (§4.2) | ActionValidForInputType fails → row filtered out entirely |
USLScreenWidget sets bIsBackHandler and bIsBackActionDisplayedInActionBar in its constructor, so every SL screen advertises its back action without per-screen setup.
#5.4 Cancel / back action
bIsBackHandler = true on the screen widget means it handles the global back action. When fired, NativeOnHandleBackAction() is called — default implementation pops the widget. Override if a modal needs to cancel sub-state before closing.
If you have nested screens, only the topmost stack widget should be the back handler at a time. CommonUI handles this — but if you set bIsBackHandler on a HUD-tier widget, it will eat the back press too. Keep it false on USLGameOverlayWidget subclasses.
#5.5 CommonUI override reference — every function you touch, per class
The single most confusing thing about CommonUI is knowing which function to override to change a behavior, because the behavior is spread across a class hierarchy and half of it is Native (C++) with a parallel BP_ (Blueprint) event. This is the map. "Where" = C++ Native… override or BP graph event.
#UCommonActivatableWidget → USLCommonActivatableWidget / USLScreenWidget (every screen, modal, overlay)
| Function | Where | Override it to… | Footgun |
|---|---|---|---|
GetDesiredFocusTarget() / BP_GetDesiredFocusTarget | BP "Get Desired Focus Target" graph override (screens) or C++ NativeGetDesiredFocusTarget | Return the control focus lands on when the screen opens. Mandatory on every screen. | Returning null = un-navigable on a controller (§6.29). BP screens must use the graph override — AutoFocusWidget is C++-only (not BP-exposed) because the CDO picker reverts + crashes. The compile guard enforces this. |
GetDesiredInputConfig() | C++ GetDesiredInputConfig | Set input routing (ECommonInputMode::Game/Menu/All), mouse capture, cursor. This is the CommonUI way to switch input mode — never call SetInputMode_* yourself. | Our base already implements it off InputModeOnActivate. Get it wrong and gameplay leaks under the menu, or the game stays dead after close (§6.30). |
NativeOnActivated() / BP_OnActivated | C++ NativeOnActivated (call Super) or BP "On Activated" | Run per-open logic (pause, bind input-method-changed, focus). | Don't redeclare BP_OnActivated in a C++ subclass (§6.2). Always call Super::NativeOnActivated(). |
NativeOnDeactivated() / BP_OnDeactivated | same | Per-close cleanup (unpause, unbind, restore game input). | Must balance whatever NativeOnActivated pushed (ref-counted suppression, delegates). |
NativeOnHandleBackAction() | C++ | Intercept B/Circle/Esc before the default pop (e.g. cancel an edit first, or confirm-on-quit). Return true if handled. | Only fires if bIsBackHandler = true. Leave that false on HUD-tier overlays or they eat the back press meant for the menu (§5.4). |
bIsBackHandler (prop) | BP Class Defaults / ctor | Mark the screen as the back handler. | USLScreenWidget sets it true; overlays must stay false. |
bIsModal (prop) | BP Class Defaults | Block input to layers beneath. | Blocking ≠ visual dim — add your own dark UImage (§6.25). |
bAutoActivate (prop) | — | Leave false. Let the stack drive activation. | True bypasses the stack lifecycle (§6.10). |
#UCommonButtonBase → USLButtonBase (every clickable)
| Function | Where | Override it to… | Footgun |
|---|---|---|---|
OnClicked() (BlueprintAssignable) | BP graph "On Clicked" or C++ NativeOnClicked | React to click and gamepad-confirm (same event). | OnDoubleClicked is mouse-only — never gate confirm logic on it (§6.18). |
NativeOnCurrentTextStyleChanged() | C++ | Re-apply the label's text style when the button's style changes. | Our base syncs ButtonText here. |
NativeOnAddedToFocusPath() / NativeOnRemovedFromFocusPath() | C++ | Surface description/tooltip on focus (controller-reachable), not just hover. | Hover is mouse-only — don't put important info in a hover tooltip (§6.19). |
NativeOnHovered() / NativeOnUnhovered() | C++ | Mouse-only hover cosmetics. | Pair with the focus-path pair above so controller users get parity. |
NativeOnSelected() / NativeOnDeselected() | C++ | Toggle/tab-button selected state (radio behavior). | Only meaningful when GetButtonSelectability allows selection. |
Style (prop) | BP Class Defaults | Give the button a CommonButtonStyle. Mandatory. | No style = invisible, label-less widget + a CommonUI warning. Menus place WBP_SL_Button, never raw USLButtonBase (Docs/ButtonWidget.md). |
#List rows — IUserObjectListEntry → USLListEntryWidget
| Function | Where | Override it to… | Footgun |
|---|---|---|---|
**NativeOnListItemObjectSet(UObject*) / "On List Item Object Set"** | C++ or BP | Populate the row from its data object. | The row is recycled/virtualized — repopulate fully every call, don't assume first-time state. |
NativeOnItemSelectionChanged(bool) / "On Item Selection Changed" | C++ or BP | React to the row being selected. | |
NativeOnEntryReleased() / "On Entry Released" | C++ or BP | Release cached refs when the row is recycled out. | Host in UCommonListView, never a hand-filled box (§6.8). |
#Tabs — UCommonTabListWidgetBase → USLTabListWidget
| Function | Where | Override it to… | Footgun |
|---|---|---|---|
HandleTabCreation_Implementation(FName, UCommonButtonBase*) | C++ | Style/label each tab button as it's created. | |
SetLinkedSwitcher() + RegisterTab…() | C++ call order | Link the switcher first, then register tabs. | Engine drives LB/RB cycling once linked — don't hand-wire tab input. |
#Action bar — UCommonBoundActionBar + UCommonBoundActionButton
| Piece | Requirement | Footgun |
|---|---|---|
Action Button widget (WBP_SL_BoundActionButton) | Must contain a CommonTextBlock named exactly Text_ActionName (engine BindWidget). | Wrong name → won't compile-bind the prompt. |
Screen RegisterBinding(FBindUIActionArgs) with bDisplayInActionBar=true | Adds A/B/X/Y prompts. | Only shows if a UCommonBoundActionBar is in the same widget tree (§5.3). |
#Input subsystem — UCommonInputSubsystem (bind, don't subclass)
| Member | Use | Footgun |
|---|---|---|
OnInputMethodChangedNative | Bind in NativeOnActivated to restore focus on gamepad after mouse use (§5.2). | On PIE the initial type is KBM until touched — force-call the handler once on activate (§6.21). |
GetCurrentInputType() | Gate "force focus" to gamepad only. | Forcing focus on KBM fights the user. |
SetInputTypeFilter() | A "lock input type" accessibility setting. | Mouse movement flips type to KBM constantly (§6.20). |
The one-screen checklist: every new screen needs exactly two things authored, everything else is inherited —
(1) a Get Desired Focus Target graph override returning its first control, and (2) buttons in a
box, not a canvas (§6.17). If both are done and it still won't navigate, open the Widget Reflector (§6.28).
#6. Footguns (Read All Of These)
Field-tested traps, ordered roughly by likelihood of getting bitten.
#6.1 BindWidget naming is silent on mismatch
We already know this from the AmmoStrip work. A meta=(BindWidget) UPROPERTY in C++ named MenuStack requires the BP widget instance to be named exactly MenuStack. Wrong name = null pointer at runtime, no warning unless you log it. Always add an ensureMsgf or UE_LOG(Warning) when a BindWidget property is null.
#6.2 UCommonActivatableWidget BP events already exist
BP_OnActivated and BP_OnDeactivated are pre-declared as BlueprintImplementableEvents on UCommonActivatableWidget. Do not redeclare them in subclasses — .h will compile but the BP event graph shows two duplicates and the wrong one fires. (We already have this memory note — feedback_commonui_includes.)
#6.3 Activatable widget container include path
UCommonActivatableWidgetStack lives in Widgets/CommonActivatableWidgetContainer.h, NOT CommonActivatableWidgetStack.h. The header file is named after the base container class.
#6.4 UCommonUserWidget vs UUserWidget
For widgets that need CommonUI features (input handling, focus integration), inherit UCommonUserWidget. For pure visual containers (a label + icon row), UUserWidget is fine. USLListEntryWidget could go either way — recommended UCommonUserWidget so focus works on row selection.
#6.5 bIsFocusable defaults
UCommonButtonBase is focusable by default (good). Plain UUserWidget is not focusable by default. If you have a custom row that should accept focus (e.g., a settings row that opens a sub-menu), call SetIsFocusable(true) in the constructor.
#6.6 Input mode flipping is order-sensitive
The sequence:
- Push widget to MenuStack
- Set input mode
GameAndUI
- Set show mouse cursor true
- Focus the widget
…must happen in that order. If you focus the widget before setting input mode, the focus is captured by the viewport which then revokes it when the input mode flips. CommonUI's GetDesiredInputConfig mechanism handles this correctly — use it instead of calling the input mode functions manually.
#6.7 SetInputMode_UIOnly is almost never what you want
In game UI, use FInputModeGameAndUI with LockMouseToViewportBehavior = LockOnCapture and bHideCursorDuringCapture = false. UIOnly blocks gameplay input entirely — fine for main menu screen, dangerous if invoked mid-game because the player has no way to recover if the menu deactivation fails.
#6.8 UCommonListView requires IUserObjectListEntry
If you use UCommonListView for settings rows, each row widget must:
- Implement
IUserObjectListEntry
- Override
NativeOnListItemObjectSet(UObject* InObject)to populate from data
- The row class is set on the ListView (
EntryWidgetClass), and you callSetListItems(TArray<UObject*>)
Don't use UListView directly — it's the base; CommonUI extends it with focus + input handling.
#6.9 Activatable widgets outside a stack don't activate
If you add a USLCommonActivatableWidget directly to a UOverlay (the HUD layer), it will not call NativeOnActivated because nothing tells it to. UCommonActivatableWidgetStack is what activates its children. If you need a HUD-tier popup that activates (gets focus, handles input), put it on GameStack instead.
#6.10 bAutoActivate is a trap
bAutoActivate = true on an activatable widget means it activates as soon as it's added to a widget tree, regardless of stack ownership. This bypasses the stack's lifecycle and causes exactly one nested deactivation bug. Leave it false and let the stack drive activation.
#6.11 GameViewportClient swap requires editor restart
The first time DefaultEngine.ini is changed to point at a new viewport client class, the editor must be restarted to pick it up. Already done for SystemLink — SLGameViewportClient is live — but worth noting if it ever gets changed again.
#6.12 Network-mode mismatch on SetGamePaused
UGameplayStatics::SetGamePaused no-ops on dedicated servers and replicated clients. On listen server it pauses for all clients. On standalone it pauses for the player. Decide intentionally whether the pause menu should pause time — for SystemLink (cooperative listen-server intent), pause is fine; if you ever go competitive dedicated, the pause menu must not affect game time.
#6.13 Save Game during pause
UGameplayStatics::SaveGameToSlot works fine while paused, but AsyncSaveGameToSlot runs on a task graph thread and the response delegate fires on game thread — if the game is paused with a modal showing "Saving…", make sure the modal's timer / animation uses Tickable or SetTickableWhenPaused so it actually animates.
#6.14 UCommonRotator for enum settings
For settings like "Quality: Low / Medium / High", UCommonRotator is way better than a dropdown on gamepad — left/right cycles the value with the D-pad without opening a list. Built into CommonUI, no custom widget needed. (Found WBP_Rotator_* in the old project's assets.)
#6.15 UCommonNumericTextBlock for animated counters
Score / XP / currency rolling animations — use UCommonNumericTextBlock instead of a tween on a plain UTextBlock. Handles localization formatting automatically.
#6.16 Text autosize traps
UCommonTextBlock has autosizing that scales the font to fit. On localized strings ("Begin" vs "Spiel Beginnen") the scaling can produce wildly different visuals across languages. Set a min font size in the text style or button will look bad in DE/RU.
#6.17 Focus arrows and CommonUI navigation
If the user reports "I can't navigate past button X" — check the panel widget. UCanvasPanel does not auto-route navigation; you have to set Navigation rules on each child widget. Use UVerticalBox / UHorizontalBox / UGridPanel for menus, not canvas, unless you manually wire each Navigation direction.
#6.18 Click vs Confirm
On UCommonButtonBase, the OnClicked event fires for both mouse click and gamepad confirm, but the OnDoubleClicked event is mouse-only. Don't gate critical confirm logic on double-click.
#6.19 Tooltip on focus, not hover
Standard UCommonButtonBase shows tooltips on hover, which is mouse-only. For a gamepad-friendly "description on focus" pattern, override NativeOnAddedToFocusPath() / NativeOnRemovedFromFocusPath() to push the description to a shared description panel. Don't rely on tooltips for important info.
#6.20 Multiple input devices simultaneously
A player with both an Xbox controller and a mouse connected: every mouse movement flips UCommonInputSubsystem back to KBM. If the player uses both intentionally (controller for movement, mouse for menus), the auto-detection will fight them. Provide a "Lock input type" setting that disables auto-detection — UCommonInputSubsystem::SetInputTypeFilter is the hook.
#6.21 Initial input type on PIE
In Editor PIE, UCommonInputSubsystem::GetCurrentInputType() starts as MouseAndKeyboard even if the player has a controller plugged in — until the player touches it. This means a controller-only player has no focus on their first menu open. The reference project's HandleInputMethodChanged(CIS->GetCurrentInputType()) call inside NativeOnActivated handles this — by force-calling the handler immediately, focus gets restored if they're already on gamepad.
#6.22 Don't push the same widget class twice
PushWidget<T>() creates a new instance every call. Pushing the pause menu twice gives you two stacked pause menus, both visible, both eating input. Either deduplicate at the push site (check GetActiveWidget(Layer)) or make the back action close all instances.
#6.23 GameInstanceSubsystem ordering
If you ever move stack management out of ASLPlayerHUD into a UGameInstanceSubsystem (common upgrade path), be aware that subsystems initialize before the LocalPlayer is created, so don't try to bind OnInputMethodChangedNative in Initialize(). Defer to OnLocalPlayerAdded instead.
#6.24 BlueprintImplementableEvent without BlueprintCallable
(Already in memory — feedback_self_documenting_code and the global CLAUDE.md). Every BlueprintImplementableEvent exposed for BP override must also be BlueprintCallable or it won't appear in BP search menus reliably.
#6.25 bIsModal is for blocking, not visuals
UCommonActivatableWidget::bIsModal (sometimes called bModal) blocks input from layers below. It doesn't dim them visually. The "darken the background" effect needs a separate UImage with semi-transparent black behind your modal contents. Cosmetic ≠ logical.
#6.26 Touch input as a third platform
CommonUI supports Touch as a first-class input type. If we never plan to ship to mobile, set TouchInputType to an empty controller data and avoid the cost. If we do, every menu needs gesture support designed in (not slapped on later).
#6.27 Pause menu and audio
Music should keep playing while paused. Set bIgnoreForPause on the audio component or set the mix on the master to bypass time dilation. Without this, every UI sound goes silent the moment the menu pauses the game.
#6.28b Focus is lost across a fade/transition between menus (CONFIRMED, cost real time)
Symptom: open menu B from menu A with a fade transition → B appears but the controller can't navigate it; no focus anywhere. Cause: CommonUI sets focus once at activation via NativeGetDesiredFocusTarget, but during the fade the target is in a non-focusable state — usually because the fade animates Visibility (Hidden/Collapsed) instead of opacity, or the widget isn't laid out yet — so SetUserFocus silently no-ops and is never retried. Fade finishes → visible but unfocused → dead on controller.
Fix (both, both one-time in the foundation):
- Fade with Render Opacity, not Visibility. Keep the widget
Visible/SelfHitTestInvisiblethroughout
(so it stays focusable); animate Render Opacity 0→1. Animating Visibility is the usual culprit.
- Re-assert focus after the transition in
USLCommonActivatableWidget(see §4.1) — not just at
activation. Use RequestRefreshFocus() (CommonUI 5.x) or a 1-frame deferred reassert: if no descendant has focus and the current input is gamepad, SetUserFocus(NativeGetDesiredFocusTarget()). Inherited by every screen → fades, tab switches, modal pops all work.
This is the single highest-ROI reason to enhance the activatable base before authoring any screen.
#6.28 The Widget Reflector is your friend
Tools → Debug → Widget Reflector. Hold Ctrl+Shift+W in PIE, click on any UI element, see the Slate tree, focused widget, hit-test path. 90% of "why isn't this focusable" / "why doesn't this respond" is solved here.
#6.29 The AutoFocusWidget CDO picker silently reverts to blank (CONFIRMED, cost a session)
Symptom: you set Auto Focus Widget = Btn_Resume in a screen's Class Defaults, compile — and the field is blank again next time you look; on a controller the wrong button (or nothing) is focused. The buttons are variables; it's not that.
Cause: a UWidget* UPROPERTY on the CDO references a widget that lives in the WidgetTree. The UMG compiler regenerates the tree on compile and can't reliably re-bind that CDO→tree reference, so it clears to null. (Repeatedly poking that details-panel object picker also tends to crash the UMG editor — PropertyPath → UMGEditor assertion in UObjectHash.)
Fix — use the graph override: in the widget Graph, override Get Desired Focus Target (BP_GetDesiredFocusTarget) and return the button variable (e.g. Btn_Resume). (As of 2026-07-09 AutoFocusWidget is no longer BP-exposed — the picker is gone entirely, so the graph override is the only BP path. AutoFocusWidget survives as a C++-only convenience.) Leave Auto Focus Widget blank. USLCommonActivatableWidget::NativeGetDesiredFocusTarget() returns AutoFocusWidget if set, else falls through to the BP override — so a blank field + this override focuses correctly, persists, and never touches the crashy picker. This is CommonUI's intended pattern; the AutoFocusWidget field stays only as a convenience for the rare case it holds. → 2026-07-09 pause-menu debug.
Enforced at compile time. USLScreenWidget::ValidateCompiledWidgetTree emits a **Widget BP compile
error** if a screen has neitherAutoFocusWidgetset nor aGet Desired Focus Targetgraph override — so
you can't ship an un-navigable screen by accident. New screens will show that error until the override is
added (that's the nudge). DowngradeCompileLog.Error→WarninginSLScreenWidget.cppif it's ever too
strict (e.g. a C++ screen subclass that overrides NativeGetDesiredFocusTarget instead).
#6.30 Closing a menu doesn't hand input back to the game (CONFIRMED)
Symptom: the pause menu closes fine, but the game is dead afterward — no mouse-look, no keyboard, clicking the viewport doesn't help. Cause: CommonUI applies a Menu input config while a screen is active but only reverts to another activatable widget's config. Our gameplay/HUD layer isn't an activatable widget, so when the last menu closes there's nothing to revert to and input routing/viewport focus is left in menu land. Fix: explicitly restore FInputModeGameOnly + SetAllUserFocusToGameViewport() when the last menu closes — hooked to the input-suppression 1→0 pop (ASLPlayerController::RestoreGameInputMode), so it fires for every close path (Resume click, B/Circle, Esc). → 2026-07-09.
#6.31 Don't use bShouldSelectUponReceivingFocus for focus highlighting — selection is sticky (CONFIRMED, cost real time)
Symptom: you enable bShouldSelectUponReceivingFocus so a focused button shows the Selected style, and now every button you navigate past stays selected — the whole menu lights up.
Cause: the flag selects on focus-in (UCommonButtonBase::HandleFocusReceived → SetIsSelected(true)) but CommonUI has no deselect on focus-out — selection is designed to persist (it's for toggles/tabs).
What does NOT work: trying to "complete the pair" by deselecting in NativeOnRemovedFromFocusPath / NativeOnFocusLost on the widget. CommonButton drives selection off its internal Slate button's focus delegates (HandleFocusReceived/HandleFocusLost), not the UUserWidget focus path — so those overrides don't reliably fire, and the buttons stay stuck selected. (We tried this; it didn't work.)
The fix — don't use Selection for focus highlighting at all. CommonUI already shows the Hovered brush on focus, natively and non-stickily (reverts to Normal when focus leaves). Style the Hovered brush as your focus look and leave bSelectable/bShouldSelectUponReceivingFocus off. In our style the Hovered brush was already the intended white-outline look, so reverting the selection flags gave the identical visual, non-sticky, zero code. → 2026-07-09. (Reserve real Selection + UCommonButtonGroupBase single-selection for genuine radio/tab state, not for "which button is focused.")
#6.32 A UCommonActionWidget shows its glyph in the designer and nothing at runtime (CONFIRMED, cost a session)
Symptom: WBP_SL_ButtonTile shows a crisp "A" in the UMG designer. In PIE the label renders and the glyph is simply absent — no warning, no log line, no empty box. Nothing looks broken; the icon just isn't there.
Cause 1 — the designer is lying to you. UCommonActionWidget::GetIcon() has a WITH_EDITORONLY_DATA branch that bypasses the entire runtime pipeline and renders DesignTimeKey. A glyph in the designer only proves DesignTimeKey is set to a gamepad key. It carries zero information about runtime. Do not use the designer to validate prompt icons — ever. Nothing about the real path is exercised until PIE.
Cause 2 — the runtime path bails silently by design. UpdateActionWidget() calls SetVisibility(ESlateVisibility::Collapsed) whenever it can't resolve a brush (Icon.DrawAs == NoDrawType), which is the correct behaviour (no glyph beats a broken glyph) but means every failure is invisible. It collapses if any link in this chain is missing:
- the
UCommonActionWidgetis named exactlyInputActionWidget(BindWidgetOptionalon
UCommonButtonBase — a mismatch binds null with no warning, §6.1);
- an action actually got pushed into it (see §6.33 — the fallback is hover-gated);
QueryKeysMappedToActionreturns a key → the IMC must be applied right now (§4.2);
- the key maps to a brush in a registered
CD_SL_*whoseGamepadNamematchesDefaultGamepadName.
Debug order (cheapest first): confirm the widget's name; then whether the IMC is applied (get_referencers on the IMC — ours was orphaned, referenced by nothing, which is what a never-applied context looks like from the outside); then dump QueryKeysMappedToAction live in PIE. Don't theorise about brushes until you've proven a key comes back. → 2026-07-15.
#6.33 The default-click glyph fallback is gated on mouse hover, so it never fires on a gamepad (CONFIRMED)
Symptom: the glyph appears when you mouse-hover a button, but navigating to it with a gamepad shows nothing — even though the button is clearly focused and DA_SL_CommonInputData.EnhancedInputClickAction is set.
Cause: UCommonButtonBase::UpdateInputActionWidget() only reaches the default-click-action fallback via bShouldUseFallbackDefaultInputAction && bButtonEnabled && IsHovered(). IsHovered() is the internal Slate button's mouse hover. NativeOnFocusReceived never sets it, and UCommonButtonBase has no focus-path handlers at all — focus ≠ hover in CommonUI. With no TriggeringInputAction / TriggeringEnhancedInputAction set, a gamepad-focused button takes the else branch, pushes an empty action, and collapses.
The tempting fix that is wrong: setting TriggeringEnhancedInputAction = IA_SL_UI_Confirm on the button. It resolves the glyph unconditionally, but it also registers a UI action binding for A (BindTriggeringInputActionToClick). FActionRouterBindingCollection::ProcessNormalInput matches bindings by registration order, gated on IsWidgetReachableForInput — which is reachability, not focus. Give all three tiles the same action and A always clicks the first one regardless of what's focused.
The fix: drive the action widget from focus on USLButtonBase (set the enhanced input action on focus received, clear it on focus lost). Glyph follows focus; no competing click binding. Per-button TriggeringInputAction is for buttons that own a distinct action (hold-to-confirm), not for "A confirms the focused thing" — that's what focus + the default click action already do. → 2026-07-15.
#6.34 "On Focus Lost" is the WRONG node on a CommonButton — its partner is "On Unfocused" (CONFIRMED, cost real time)
Symptom: you drive a focus visual (border DMI, highlight) from On Focused → set, On Focus Lost → clear. Focus-in works; focus-out never fires, so every button you navigate past stays lit. Looks identical to sticky selection (§6.31) but selection is off and the style brushes are innocent — a real red herring.
Cause: two different classes expose confusingly-named focus events, and the BP palette shows both:
| Node label | Actual source | Fires on a CommonButton? |
|---|---|---|
| On Focused | UCommonButtonBase::BP_OnFocusReceived | ✅ |
| On Unfocused | UCommonButtonBase::BP_OnFocusLost | ✅ ← the real partner |
| On Focus Received | UUserWidget::OnFocusReceived (returns FEventReply) | ❌ |
| On Focus Lost | UUserWidget::OnFocusLost (has an In Focus Event pin) | ❌ never fires |
UCommonButtonBase::NativeOnFocusReceived immediately forwards user focus to its internal SCommonButton (SetUserFocus(RootButton->GetCommonButton())), so the UUserWidget never holds focus and therefore never loses it. CommonButton's own events are bound to the internal button's delegates (RootButton->OnReceivedFocus/OnLostFocus → HandleFocusReceived/HandleFocusLost, CommonButtonBase.cpp:503). On Focused (CommonButton) paired with On Focus Lost (UUserWidget) is a mismatched pair from two classes — set fires, clear doesn't.
Tell them apart in the graph: the UUserWidget one has an In Focus Event pin. CommonButton's has none.
Fix: pair On Focused with On Unfocused. Never mix the two families.
Related asymmetry (why the description events DO work): focus-path events (NativeOnAddedToFocusPath / NativeOnRemovedFromFocusPath, used by USLButtonBase for descriptions) fire correctly, because the path includes ancestors — the widget is in the focus path even though the inner Slate button holds the focus. Direct focus events are the ones that don't. Same root cause as §6.31's failed deselect hook. → 2026-07-15.
#6.35 The bound action bar: back doesn't display, and a missing glyph widget blanks the LABEL (CONFIRMED)
Three separate traps, each producing "the command bar doesn't work":
(a) bIsBackActionDisplayedInActionBar defaults to false. bIsBackHandler = true makes B/Esc function; advertising that action to the bar is a separate opt-in (CommonActivatableWidget.h:193). Back works but never appears — which reads as a broken action bar rather than an unset flag. USLScreenWidget's constructor sets both, so don't re-diagnose this per screen.
(b) No InputActionWidget ⇒ blank button, INCLUDING the text. The whole of UCommonBoundActionButton::UpdateInputActionWidget() is wrapped in if (InputActionWidget) — and Text_ActionName->SetText(...) lives inside that check:
if (InputActionWidget) //optional bound widget
{
InputActionWidget->SetInputActionBinding(BindingHandle);
FText ActionDisplayName = BindingHandle.GetDisplayName();
...
if (Text_ActionName) { Text_ActionName->SetText(ActionDisplayName); }
OnUpdateInputAction();
}
So the entry widget needs both InputActionWidget (a UCommonActionWidget) and Text_ActionName (a UCommonTextBlock), named exactly — both are BindWidgetOptional, so a name typo is silent (§6.1). Authoring only Text_ActionName (as an earlier CurrentFocus note suggested) yields a button that renders nothing, and the empty label sends you hunting the text when the glyph widget is what's missing.
(c) Blank label with a correct glyph = no display name. Precedence is OverrideDisplayName → else InputAction->ActionDescription (UIActionRouterTypes.cpp:124/169). On the enhanced-input path with both empty, the glyph resolves and the text is empty. Set ActionDescription on the IA_SL_UI_* asset (data-driven, serves every binding) or OverrideBackActionDisplayName per screen.
Not a requirement: the bar does not need to be in the same widget tree — it reads the local player's action router (§5.3). → 2026-07-15.
#7. Build Order
Recommended sequence. Each step is small, testable in isolation, and doesn't depend on later steps to work.
- Author UI input actions + IMC (
IA_UI_*,IMC_SL_UI) — empty handlers, just wire keys.
Test: enable IMC programmatically, press gamepad A, verify IA_UI_Confirm triggers in a print.
- Author CommonUI data assets —
DT_SL_InputActions,DA_SL_CommonInputData, four
CD_SL_* controller data. Register in DefaultGame.ini. Test: add a UCommonBoundActionBar to the existing HUD widget, set its InputActions to Confirm+Back, verify icons render at runtime (Xbox vs KBM swap).
- Enhance
USLCommonActivatableWidget—GetDesiredFocusTarget,GetDesiredInputConfig,
OnInputMethodChangedNative subscription, bPauseGameWhileActive. Test: small WBP_SL_TestScreen with one button, push it via PIE console, verify focus on activation + focus restore on KBM↔gamepad transitions.
- Author
USLButtonBase— portSystemLinkButtonBasefrom the old project. Reskin in
WBP_SL_Button_Default. Verify it appears in BP dropdowns and navigation between two of them in a VerticalBox works on gamepad.
- Pop / clear API on
USLPrimaryGameLayout—PopWidgetFromLayer,GetActiveWidget,
ClearLayer. Test: push two widgets, pop top, verify second activates.
- Pause menu —
WBP_SL_PauseMenu(subclass ofUSLScreenWidget). Three buttons: Resume,
Settings, Quit. Bind IA_UI_Pause at PC level → ASLPlayerController::OpenPauseMenu. Test: F-key to pause, B/Esc to close, verify gameplay input blocked while menu open.
USLModalWidget— Ok / YesNo modal with action bar. Quit-to-desktop confirm flow uses it.
USLTabListWidget— tab + content switcher for settings.
- Settings page skeleton —
WBP_SL_Settings(tabs: Video / Audio / Controls), three blank
tab content widgets.
- Settings rows —
USLListEntryWidget+USLSettingsRow_Toggle,_Slider,_Rotator,
_KeyBind. Persist to USaveGame later (separate work item).
- Main menu — only needed if we go to a level-select pattern; not blocking gameplay work.
#8. Open Questions / Decisions
Before any of the above lands, get these answered:
- Pause behavior in multiplayer — does the pause menu pause time? (If listen server, yes is
fine. If dedicated future-proofing, no — menu UI shows but game runs.)
- Settings persistence —
USaveGameslot?GameUserSettings.ini?JSON? Affects how
settings rows wire their values.
- Localization — out of scope for now, but every
FTextin the base classes should use
NSLOCTEXT macro so it's localizable later without a global find/replace.
- Input remapping — first-class feature, or save-for-later? If first-class, the runtime
UEnhancedInputUserSettings API (UE 5.3+) is the path; if save-for-later, plan a stub UI.
- Initial controller — at game start, do we assume KBM or auto-detect? Auto-detect is the
default; if the first menu the user sees is the main menu and they're on controller, focus must land correctly the first time (see §6.21).
- Modal stacking — can a modal push another modal? Yes from a UI standpoint, but design-wise
it usually indicates a flow problem. Decide policy.
#9. References
- Reference project:
C:\3D-DEV\HaloProject\SystemLink\Source\SystemLink\UI\— 4 base classes,
modal pattern, input data table example. UE 5.4 era, port not copy.
- Current scaffolding:
Plugins/SystemLinkCore/Source/SystemLinkCore/Public/UI/— layout, HUD,
activatable base, viewport client.
- Lyra:
Lyra/Plugins/CommonGame/Source/CommonGame/Public/CommonActivatableWidget.h— gold
standard. Too large to copy whole, but UCommonGameViewportClient, UCommonLocalPlayer patterns there are worth a read.
- Epic docs: <https://dev.epicgames.com/documentation/en-us/unreal-engine/common-ui-plugin-for-unreal-engine>
(link not generated — search "Common UI Plugin" in the UE docs site).