Lots of work on focus management

This commit is contained in:
2026-04-19 03:01:23 -04:00
parent fd970f20c3
commit dabb5b8f0b
8 changed files with 266 additions and 84 deletions

View File

@@ -4,7 +4,7 @@
[/Script/Engine.Engine]
GameViewportClientClassName=/Script/CommonUI.CommonGameViewportClient
GameViewportClientClassName=/Script/Integration.lxViewportClient
[/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Game/LpxLevel.LpxLevel

Binary file not shown.

View File

@@ -1,10 +1,10 @@
////////////////////////////////////////////////////////////
//
// InputEvents.cpp
// InputModeRequest.cpp
//
////////////////////////////////////////////////////////////
#include "InputEvents.h"
#include "InputModeRequest.h"
#include "Common.h"
bool FlxInputModeRequest::operator==(const FlxInputModeRequest &Other) const
@@ -12,7 +12,8 @@ bool FlxInputModeRequest::operator==(const FlxInputModeRequest &Other) const
return (Widget == Other.Widget) &&
(Focus == Other.Focus) &&
(ShowPointer == Other.ShowPointer) &&
(BlockInput == Other.BlockInput);
(BlockInput == Other.BlockInput) &&
(EnableInputComponent == Other.EnableInputComponent);
}
bool FlxInputModeRequests::SanityCheck(const FlxInputModeRequest &Request)
@@ -42,60 +43,55 @@ void FlxInputModeRequests::Request(const FlxInputModeRequest &NewRequest)
View High, Low;
SplitHighLow(High, Low);
// This is a simple test to see if anything is going to change.
// If not, we return early and avoid setting the dirty bit.
if (IsHigh)
{
if ((High.Num() > 0) && (High[0] == NewRequest)) return;
}
else
{
if ((Low.Num() > 0) && (Low[0] == NewRequest)) return;
}
// Stamp this request with a fresh sequence number.
FlxInputModeRequest Stamped = NewRequest;
Stamped.SequenceNumber = ++NextSequenceNumber;
// We're going to build a new version of the requests array.
TArray<FlxInputModeRequest> Updated;
// Add all high priority requests to the updated array, new request first.
if (IsHigh) Updated.Add(NewRequest);
if (IsHigh) Updated.Add(Stamped);
for (const FlxInputModeRequest &Req : High)
if (Req.Widget != NewRequest.Widget) Updated.Add(Req);
// Add all low priority requests to the updated array, new request first.
if (!IsHigh) Updated.Add(NewRequest);
if (!IsHigh) Updated.Add(Stamped);
for (const FlxInputModeRequest &Req : Low)
if (Req.Widget != NewRequest.Widget) Updated.Add(Req);
Swap(Requests, Updated);
Dirty = true;
}
void FlxInputModeRequests::EnsureWidget(UUserWidget *Widget)
void FlxInputModeRequests::SetEnableInputComponent(UUserWidget *Widget, bool EnableInputComponent)
{
for (const FlxInputModeRequest &Req : Requests)
for (FlxInputModeRequest &Req : Requests)
{
if (Req.Widget == Widget) return;
if (Req.Widget == Widget)
{
Req.EnableInputComponent = EnableInputComponent;
return;
}
Request(FlxInputModeRequest(Widget, nullptr, false, false));
}
FlxInputModeRequest NewReq;
NewReq.Widget = Widget;
NewReq.EnableInputComponent = EnableInputComponent;
Request(NewReq);
}
void FlxInputModeRequests::Remove(UUserWidget *Widget)
{
int32 N = Requests.Num();
Requests.RemoveAll([Widget](const FlxInputModeRequest &Entry)
{
return Entry.Widget == Widget;
});
if (Requests.Num() < N) Dirty = true;
}
void FlxInputModeRequests::GarbageCollect()
{
int32 N = Requests.Num();
Requests.RemoveAll([](const FlxInputModeRequest &Entry)
{
return !IsValid(Entry.Widget);
});
if (Requests.Num() < N) Dirty = true;
}

View File

@@ -1,6 +1,6 @@
////////////////////////////////////////////////////////////
//
// InputEvents.h
// InputModeRequest.h
//
// Custom input event dispatching system. Uses Unreal's
// built-in input modes (GameOnly / UIOnly) with an
@@ -12,21 +12,17 @@
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "InputEvents.generated.h"
#include "InputModeRequest.generated.h"
////////////////////////////////////////////////////////////
//
// FlxInputModeRequest
//
// A widget's declaration of interest in input events.
//
// Widget: The widget that wants to receive events.
// Focus: The widget that should receive keyboard focus
// while this request is active.
// ShowPointer: If true, the mouse pointer should be
// visible when this widget has control.
// BlockInput: If true, input actions should be blocked from
// reaching lower-priority consumers (e.g. the pawn).
// Using this struct, a Widget can express a need for a
// particular input mode. These requests go to the player
// controller, which arbitrates.
//
////////////////////////////////////////////////////////////
@@ -36,8 +32,6 @@ struct FlxInputModeRequest
GENERATED_BODY()
FlxInputModeRequest() = default;
FlxInputModeRequest(UUserWidget *InWidget, UWidget *InFocus, bool InShowPointer, bool InBlockInput)
: Widget(InWidget), Focus(InFocus), ShowPointer(InShowPointer), BlockInput(InBlockInput) {}
bool operator == (const FlxInputModeRequest &Other) const;
@@ -56,6 +50,13 @@ struct FlxInputModeRequest
UPROPERTY(BlueprintReadWrite)
bool BlockInput = false;
UPROPERTY(BlueprintReadWrite)
bool EnableInputComponent = true;
UPROPERTY()
int32 SequenceNumber = 0;
};
USTRUCT()
@@ -65,12 +66,11 @@ struct FlxInputModeRequests
private:
UPROPERTY()
// High priority requests are always before low-priority.
// Otherwise, these are in order of most recent first.
// Sorted by highest priority first, then most recent first.
TArray<FlxInputModeRequest> Requests;
UPROPERTY()
bool Dirty = true;
int32 NextSequenceNumber = 0;
public:
using View = TArrayView<FlxInputModeRequest>;
@@ -87,22 +87,13 @@ public:
// Apply a request. Replaces any previous request by the same widget.
void Request(const FlxInputModeRequest &NewRequest);
// Ensure the specified widget has a request in the array.
// If it isn't already present, register a low-priority request for it.
void EnsureWidget(UUserWidget *Widget);
// Find the specified widget, and modify the 'EnableInputComponent'
// flag. Adds the widget if it's not already present.
void SetEnableInputComponent(UUserWidget *Widget, bool EnableInputComponent);
// Remove all requests by the specified widget.
void Remove(UUserWidget *Widget);
// Remove any requests by dead widgets or widgets with no parents.
void GarbageCollect();
// Return true if the requests have changed since the last ClearDirty.
bool IsDirty() { return Dirty; }
// Clear the dirty flag.
void ClearDirty() { Dirty = false; }
// Set the dirty flag.
void SetDirty() { Dirty = true; }
};

View File

@@ -0,0 +1,62 @@
////////////////////////////////////////////////////////////
//
// LuprexViewportClient.cpp
//
////////////////////////////////////////////////////////////
#include "LuprexViewportClient.h"
#include "Common.h"
#include "PlayerControllerBase.h"
#include "Engine/GameInstance.h"
#include "Framework/Application/SlateApplication.h"
#include "Layout/WidgetPath.h"
#include "Widgets/SViewport.h"
UlxViewportClient::UlxViewportClient(const FObjectInitializer &ObjectInitializer)
: Super(ObjectInitializer)
{
UE_LOG(LogLuprexIntegration, Display, TEXT("UlxViewportClient constructed"));
}
bool UlxViewportClient::InputKey(const FInputKeyEventArgs &EventArgs)
{
UE_LOG(LogLuprexIntegration, Display, TEXT("UlxViewportClient::InputKey key=%s event=%d"),
*EventArgs.Key.ToString(), (int32)EventArgs.Event);
// Only act on left mouse button presses that bubbled up to the
// viewport unhandled. Walk the widget path under the cursor and
// find the nearest focusable ancestor of whatever was hit. If it
// isn't the game viewport itself, hand it to the player controller
// to apply on its next UpdateInputMode pass; that's the point in
// the frame where we can override SViewport's own click-focus
// behaviour without fighting it.
if ((EventArgs.Event == IE_Pressed) && (EventArgs.Key == EKeys::LeftMouseButton))
{
FSlateApplication &Slate = FSlateApplication::Get();
FVector2D MousePos = Slate.GetCursorPos();
FWidgetPath Path = Slate.LocateWindowUnderMouse(
MousePos, Slate.GetInteractiveTopLevelWindows());
if (Path.IsValid())
{
TSharedPtr<SViewport> ViewportWidget = GetGameViewportWidget();
for (int32 Idx = Path.Widgets.Num() - 1; Idx >= 0; --Idx)
{
TSharedRef<SWidget> Widget = Path.Widgets[Idx].Widget;
if (!Widget->SupportsKeyboardFocus()) continue;
if (ViewportWidget.IsValid() && Widget == ViewportWidget) break;
if (UGameInstance *GI = GetGameInstance())
{
if (AlxPlayerControllerBase *PC = Cast<AlxPlayerControllerBase>(
GI->GetFirstLocalPlayerController(GetWorld())))
{
PC->ClickToFocus(Widget);
}
}
break;
}
}
}
return Super::InputKey(EventArgs);
}

View File

@@ -0,0 +1,28 @@
////////////////////////////////////////////////////////////
//
// LuprexViewportClient.h
//
// Custom game viewport client. Implements a project-wide
// click-to-focus rule: when a left-mouse-button click is
// not handled by any widget and bubbles up to the viewport,
// we hit-test under the cursor and focus the nearest
// focusable ancestor of whatever was hit.
//
////////////////////////////////////////////////////////////
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameViewportClient.h"
#include "LuprexViewportClient.generated.h"
UCLASS()
class INTEGRATION_API UlxViewportClient : public UGameViewportClient
{
GENERATED_BODY()
public:
UlxViewportClient(const FObjectInitializer &ObjectInitializer);
virtual bool InputKey(const FInputKeyEventArgs &EventArgs) override;
};

View File

@@ -8,6 +8,8 @@
#include "Engine/GameViewportClient.h"
#include "Engine/LevelScriptActor.h"
#include "Engine/LocalPlayer.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Application/SlateUser.h"
#include "Kismet/GameplayStatics.h"
#include "Widgets/SViewport.h"
@@ -59,6 +61,40 @@ FVector2D AlxPlayerControllerBase::GetLookAtPixel(const UObject *Context)
return ScreenPosition;
}
void AlxPlayerControllerBase::BeginPlay()
{
Super::BeginPlay();
if (FSlateApplication::IsInitialized())
{
FocusChangingHandle = FSlateApplication::Get().OnFocusChanging().AddUObject(
this, &AlxPlayerControllerBase::HandleFocusChanging);
}
}
void AlxPlayerControllerBase::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (FocusChangingHandle.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().OnFocusChanging().Remove(FocusChangingHandle);
FocusChangingHandle.Reset();
}
Super::EndPlay(EndPlayReason);
}
void AlxPlayerControllerBase::HandleFocusChanging(
const FFocusEvent &FocusEvent,
const FWeakWidgetPath &OldPath,
const TSharedPtr<SWidget> &OldFocusedWidget,
const FWidgetPath &NewPath,
const TSharedPtr<SWidget> &NewFocusedWidget)
{
UE_LOG(LogLuprexIntegration, Display,
TEXT("Focus changing: '%s' -> '%s' (cause: %s)"),
OldFocusedWidget.IsValid() ? *OldFocusedWidget->GetTypeAsString() : TEXT("<none>"),
NewFocusedWidget.IsValid() ? *NewFocusedWidget->GetTypeAsString() : TEXT("<none>"),
*UEnum::GetValueAsString(FocusEvent.GetCause()));
}
UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *Widget)
{
if (!IsValid(Widget)) return nullptr;
@@ -75,7 +111,7 @@ UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *W
return Cast<UInputComponent>(Value);
}
void AlxPlayerControllerBase::WidgetRequestInputMode(UUserWidget *Widget, bool ShowPointer, bool BlockInput, UWidget *Focus)
void AlxPlayerControllerBase::WidgetRequestInputMode(UUserWidget *Widget, bool ShowPointer, bool BlockInput, UWidget *Focus, bool EnableInputComponent)
{
if (!IsValid(Widget))
{
@@ -91,7 +127,13 @@ void AlxPlayerControllerBase::WidgetRequestInputMode(UUserWidget *Widget, bool S
*Widget->GetName(), *GetNameSafe(OwningPC));
return;
}
PC->InputModeRequests.Request(FlxInputModeRequest(Widget, Focus, ShowPointer, BlockInput));
FlxInputModeRequest Req;
Req.Widget = Widget;
Req.Focus = Focus;
Req.ShowPointer = ShowPointer;
Req.BlockInput = BlockInput;
Req.EnableInputComponent = EnableInputComponent;
PC->InputModeRequests.Request(Req);
}
void AlxPlayerControllerBase::PushInputComponent(UInputComponent* InInputComponent)
@@ -103,7 +145,7 @@ void AlxPlayerControllerBase::PushInputComponent(UInputComponent* InInputCompone
// focus, pointer visibility, and input blocking across them.
if (UUserWidget *Widget = Cast<UUserWidget>(InInputComponent->GetOuter()))
{
InputModeRequests.EnsureWidget(Widget);
InputModeRequests.SetEnableInputComponent(Widget, true);
return;
}
CurrentInputStack.RemoveSingle(InInputComponent);
@@ -115,6 +157,12 @@ bool AlxPlayerControllerBase::PopInputComponent(UInputComponent* InInputComponen
{
if (InInputComponent)
{
if (UUserWidget *Widget = Cast<UUserWidget>(InInputComponent->GetOuter()))
{
InputModeRequests.SetEnableInputComponent(Widget, false);
InInputComponent->ClearBindingValues();
return true;
}
if (CurrentInputStack.RemoveSingle(InInputComponent) > 0)
{
InInputComponent->ClearBindingValues();
@@ -197,11 +245,13 @@ void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputSta
const TArray<FlxInputModeRequest> &Requests = InputModeRequests.GetRequests();
for (int32 Idx = Requests.Num() - 1; Idx >= 0; --Idx)
{
UUserWidget *Widget = Requests[Idx].Widget;
const FlxInputModeRequest &Req = Requests[Idx];
if (!Req.EnableInputComponent) continue;
UUserWidget *Widget = Req.Widget;
if (!Widget->IsInViewport()) continue;
if (UInputComponent *IC = GetWidgetInputComponent(Widget))
{
IC->bBlockInput = Requests[Idx].BlockInput;
IC->bBlockInput = Req.BlockInput;
InputStack.Push(IC);
}
}
@@ -220,6 +270,8 @@ void AlxPlayerControllerBase::UpdateInputMode()
if (!ViewportWidget.IsValid()) return;
TSharedRef<SViewport> ViewportWidgetRef = ViewportWidget.ToSharedRef();
FReply &SlateOperations = LocalPlayer->GetSlateOperations();
TSharedPtr<FSlateUser> SlateUser = LocalPlayer->GetSlateUser();
if (!SlateUser.IsValid()) return;
// The first active entry in InputModeRequests dictates the
// pointer / capture / focus state. If there are no requests at all,
@@ -235,41 +287,66 @@ void AlxPlayerControllerBase::UpdateInputMode()
if (Top->ShowPointer)
{
// Pointer-visible mode: full macroexpansion of SetInputModeGameAndUI
// — the BP wrapper, APlayerController::SetInputMode, and
// FInputModeGameAndUI::ApplyInputMode all inlined. Defaults:
// MouseLockMode=DoNotLock, WidgetToFocus=null, bHideCursorDuringCapture=true.
SlateOperations.ReleaseMouseLock();
// Only release capture if the viewport is currently holding it
// (e.g. we just came from GameOnly). A blanket ReleaseMouseCapture
// every tick would yank capture away from widgets mid-gesture
// (scrollbar drags, slider drags, etc.).
if (SlateUser->DoesWidgetHaveAnyCapture(ViewportWidget))
{
SlateOperations.ReleaseMouseCapture();
}
SlateOperations.ReleaseMouseLock();
GameViewportClient->SetMouseLockMode(EMouseLockMode::DoNotLock);
GameViewportClient->SetHideCursorDuringCapture(false);
GameViewportClient->SetMouseCaptureMode(EMouseCaptureMode::CaptureDuringMouseDown);
}
else
{
// Pointer-invisible mode: full macroexpansion of SetInputModeGameOnly
// — the BP wrapper, APlayerController::SetInputMode, and
// FInputModeGameOnly::ApplyInputMode all inlined.
// Also captures the mouse to the viewport.
SlateOperations.UseHighPrecisionMouseMovement(ViewportWidgetRef);
SlateOperations.LockMouseToWidget(ViewportWidgetRef);
GameViewportClient->SetMouseLockMode(EMouseLockMode::LockOnCapture);
GameViewportClient->SetMouseCaptureMode(EMouseCaptureMode::CapturePermanently);
}
// UWidget holds its live SWidget in a cached TSharedPtr; we need a
// TSharedRef for SetUserFocus. Fall back to the viewport if the
// target has never been constructed (cached widget is null).
TSharedPtr<SWidget> SlateFocus = Top->Focus ? Top->Focus->GetCachedWidget() : nullptr;
if (SlateFocus.IsValid())
GameViewportClient->SetIgnoreInput(false);
// How we handle focus depends on whether we're showing the pointer.
// In pointer mode, we set focus to the desired state just once,
// and then we let the pointer control it from there on. In
// no-pointer mode, we set focus to the desired state and
// keep putting it back forever.
//
// If the user clicks the mouse on a focusable widget, the
// viewport client notifies us of that fact. We then focus the
// widget if possible.
//
if ((!Top->ShowPointer) || (LastRequestGrantedFocus != Top->SequenceNumber))
{
if (Top->Focus)
{
if (TSharedPtr<SWidget> SlateFocus = Top->Focus->GetCachedWidget())
{
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
LastRequestGrantedFocus = Top->SequenceNumber;
}
}
else
{
SlateOperations.SetUserFocus(ViewportWidgetRef);
LastRequestGrantedFocus = Top->SequenceNumber;
}
}
else if (TSharedPtr<SWidget> ClickedWidget = ClickToFocusTarget.Pin())
{
SlateOperations.SetUserFocus(ClickedWidget.ToSharedRef());
}
ClickToFocusTarget.Reset();
}
GameViewportClient->SetIgnoreInput(false);
void AlxPlayerControllerBase::ClickToFocus(TSharedRef<SWidget> Widget)
{
ClickToFocusTarget = Widget.ToWeakPtr();
}
void AlxPlayerControllerBase::UpdateLookAt()

View File

@@ -3,7 +3,7 @@
#include "CoreMinimal.h"
#include "Engine/HitResult.h"
#include "GameFramework/PlayerController.h"
#include "InputEvents.h"
#include "InputModeRequest.h"
#include "PlayerControllerBase.generated.h"
UCLASS(BlueprintType, Blueprintable)
@@ -41,6 +41,20 @@ public:
// eventually reconcile focus, pointer, and capture state.
void UpdateInputMode();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
FDelegateHandle FocusChangingHandle;
void HandleFocusChanging(
const struct FFocusEvent &FocusEvent,
const class FWeakWidgetPath &OldPath,
const TSharedPtr<class SWidget> &OldFocusedWidget,
const class FWidgetPath &NewPath,
const TSharedPtr<class SWidget> &NewFocusedWidget);
public:
// Input stack overrides: unsorted, append-on-push.
virtual void PushInputComponent(UInputComponent* InInputComponent) override;
virtual bool PopInputComponent(UInputComponent* InInputComponent) override;
@@ -55,8 +69,8 @@ public:
// (thanks to DefaultToSelf + HideSelfPin): the widget self-binds,
// we find its owning PlayerController, and register the request.
UFUNCTION(BlueprintCallable, Category = "Luprex|Input Mode",
meta = (DefaultToSelf = "Widget", HideSelfPin = "true"))
static void WidgetRequestInputMode(class UUserWidget *Widget, bool ShowPointer, bool BlockInput, class UWidget *Focus);
meta = (DefaultToSelf = "Widget", HideSelfPin = "true", EnableInputComponent = "true"))
static void WidgetRequestInputMode(class UUserWidget *Widget, bool ShowPointer, bool BlockInput, class UWidget *Focus, bool EnableInputComponent);
// Get the player controller, cast to AlxPlayerControllerBase.
static AlxPlayerControllerBase *FromContext(const UObject *Context);
@@ -64,8 +78,22 @@ public:
UPROPERTY()
FHitResult CurrentLookAt;
// Input mode requests - see InputModeRequest.h for an explanation.
UPROPERTY()
FlxInputModeRequests InputModeRequests;
// The last input mode request whose focus request was granted.
UPROPERTY()
int32 LastRequestGrantedFocus = 0;
// The viewport client uses this to notify us that the user
// clicked on a focusable widget.
void ClickToFocus(TSharedRef<SWidget> Widget);
private:
TWeakPtr<SWidget> ClickToFocusTarget;
public:
bool MustCallLookAtChanged = false;
};