Much work on input mode switching

This commit is contained in:
2026-04-17 23:43:28 -04:00
parent 7a09da8a4e
commit 6388de9b39
12 changed files with 251 additions and 101 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
* UE Wingman rename functions.
* ue Wingman 'structprop' doesn't work for UWingXXXRef types, or for Widget slots. It needs to be implemented on top of getdetails.
* In the console, do not allow multi-line lua expressions unless it's something that reasonably should be multi-line, like a function definition or an if-statement.
* Keyboard Event Handling

View File

@@ -341,7 +341,11 @@ FString FWingProperty::GetText() const
return TEXT("None");
}
FString Result;
Prop->ExportTextItem_InContainer(Result, Container, nullptr, nullptr, PPF_None);
// DefaultValue == PropertyValue makes ExportText_Direct's Data==Delta
// short-circuit fire for every subfield, so default-valued fields still
// get emitted (e.g. SizeRule=Automatic on FSlateChildSize).
const void* Value = Prop->ContainerPtrToValuePtr<void>(Container);
Prop->ExportTextItem_Direct(Result, Value, /*DefaultValue=*/Value, nullptr, PPF_None);
return Result;
}

View File

@@ -13,7 +13,7 @@ import socket
HOST = "localhost"
PORT = 9851
CONNECT_TIMEOUT = 2
READ_TIMEOUT = 120
READ_TIMEOUT = 30
TOOL_DESCRIPTION = (
"Send a command to the Unreal Editor's UE Wingman plugin. "
@@ -87,12 +87,6 @@ def forward_to_editor(arguments):
"""Forward arguments to the editor, return the result dict."""
if not connect():
return {"error": "Unreal Editor is not running. Start the editor and try again."}
try:
return send_and_receive(arguments)
except Exception:
disconnect()
# Retry once in case the connection was stale
if connect():
try:
return send_and_receive(arguments)
except Exception:
@@ -104,6 +98,28 @@ def make_jsonrpc(msg_id, result):
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def parse_editor_response(result):
"""Parse and validate a raw editor response into an MCP content list.
MCP expects `content` to be a list of objects, each with at least a
string "type" field (e.g. {"type": "text", "text": "..."}). Anything
else is replaced with a single error item so the client sees a clear
message instead of a schema violation.
"""
try:
parsed = json.loads(result)
except json.JSONDecodeError:
return [{"type": "text", "text": "Malformed response from editor: invalid JSON."}]
if not isinstance(parsed, list):
return [{"type": "text", "text": "Malformed response from editor: expected a list."}]
for item in parsed:
if not isinstance(item, dict):
return [{"type": "text", "text": "Malformed response from editor: list item is not an object."}]
if not isinstance(item.get("type"), str):
return [{"type": "text", "text": "Malformed response from editor: item missing string 'type' field."}]
return parsed
def handle_message(msg):
"""Handle one JSON-RPC message from Claude Code."""
msg_id = msg.get("id")
@@ -130,10 +146,7 @@ def handle_message(msg):
if isinstance(result, dict) and "error" in result:
content = [{"type": "text", "text": result["error"]}]
else:
try:
content = json.loads(result)
except json.JSONDecodeError:
content = [{"type": "text", "text": "Malformed response from editor."}]
content = parse_editor_response(result)
return make_jsonrpc(msg_id, {
"content": content,
})

View File

@@ -7,52 +7,44 @@
#include "InputEvents.h"
#include "Common.h"
bool FlxEventRequest::operator==(const FlxEventRequest &Other) const
bool FlxInputModeRequest::operator==(const FlxInputModeRequest &Other) const
{
return (Widget == Other.Widget) &&
(UseUIOnly == Other.UseUIOnly) &&
(Focus == Other.Focus) &&
(ShowPointer == Other.ShowPointer) &&
(Hotkeys == Other.Hotkeys);
(BlockInput == Other.BlockInput);
}
bool FlxEventRequests::SanityCheck(const FlxEventRequest &Request)
bool FlxInputModeRequests::SanityCheck(const FlxInputModeRequest &Request)
{
if (Request.Widget == nullptr)
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents called with null widget."));
return false;
}
if (Request.ShowPointer && !Request.UseUIOnly)
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents: ShowPointer requires UseUIOnly."));
return false;
}
if (Request.UseUIOnly && !Request.Hotkeys.IsEmpty())
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents: Widget asked for all events, and also, specific events"));
return false;
}
return true;
}
void FlxEventRequests::SplitHighLow(View &High, View &Low)
void FlxInputModeRequests::SplitHighLow(View &High, View &Low)
{
int32 NumHigh = 0;
while ((NumHigh < Requests.Num()) && (Requests[NumHigh].UseUIOnly)) NumHigh++;
while ((NumHigh < Requests.Num()) && (Requests[NumHigh].IsHighPrio())) NumHigh++;
int32 NumLow = Requests.Num() - NumHigh;
High = View(Requests.GetData(), NumHigh);
Low = View(Requests.GetData() + NumHigh, NumLow);
}
void FlxEventRequests::Request(const FlxEventRequest &NewRequest)
void FlxInputModeRequests::Request(const FlxInputModeRequest &NewRequest)
{
bool IsHigh = NewRequest.IsHighPrio();
// Divide the array into a high-priority slice and a low-priority slice.
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 (NewRequest.UseUIOnly)
if (IsHigh)
{
if ((High.Num() > 0) && (High[0] == NewRequest)) return;
}
@@ -62,36 +54,45 @@ void FlxEventRequests::Request(const FlxEventRequest &NewRequest)
}
// We're going to build a new version of the requests array.
TArray<FlxEventRequest> Updated;
TArray<FlxInputModeRequest> Updated;
// Add all high priority requests to the updated array, new request first.
if (NewRequest.UseUIOnly) Updated.Add(NewRequest);
for (const FlxEventRequest &Req : High)
if (IsHigh) Updated.Add(NewRequest);
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 (!NewRequest.UseUIOnly) Updated.Add(NewRequest);
for (const FlxEventRequest &Req : Low)
if (!IsHigh) Updated.Add(NewRequest);
for (const FlxInputModeRequest &Req : Low)
if (Req.Widget != NewRequest.Widget) Updated.Add(Req);
Swap(Requests, Updated);
Dirty = true;
}
void FlxEventRequests::Remove(UUserWidget *Widget)
void FlxInputModeRequests::EnsureWidget(UUserWidget *Widget)
{
for (const FlxInputModeRequest &Req : Requests)
{
if (Req.Widget == Widget) return;
}
Request(FlxInputModeRequest(Widget, nullptr, false, false));
}
void FlxInputModeRequests::Remove(UUserWidget *Widget)
{
int32 N = Requests.Num();
Requests.RemoveAll([Widget](const FlxEventRequest &Entry)
Requests.RemoveAll([Widget](const FlxInputModeRequest &Entry)
{
return Entry.Widget == Widget;
});
if (Requests.Num() < N) Dirty = true;
}
void FlxEventRequests::GarbageCollect()
void FlxInputModeRequests::GarbageCollect()
{
int32 N = Requests.Num();
Requests.RemoveAll([](const FlxEventRequest &Entry)
Requests.RemoveAll([](const FlxInputModeRequest &Entry)
{
UUserWidget *W = Entry.Widget;
return W == nullptr || !IsValid(W) || W->GetParent() == nullptr;
@@ -99,14 +100,3 @@ void FlxEventRequests::GarbageCollect()
if (Requests.Num() < N) Dirty = true;
}
FlxEventRequests::InputMode FlxEventRequests::GetRequestedMode() const
{
if ((Requests.Num() > 0) && (Requests[0].UseUIOnly))
{
return InputMode::UIOnly;
}
else
{
return InputMode::GameOnly;
}
}

View File

@@ -11,52 +11,55 @@
#pragma once
#include "CoreMinimal.h"
#include "InputCoreTypes.h"
#include "Blueprint/UserWidget.h"
#include "InputEvents.generated.h"
////////////////////////////////////////////////////////////
//
// FlxEventRequest
// FlxInputModeRequest
//
// A widget's declaration of interest in input events.
//
// Widget: The widget that wants to receive events.
// UseUIOnly: If true, activating this request puts
// the system into UIOnly mode.
// 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.
// Hotkeys: Keys that go to this widget when the
// player is in GameOnly mode.
// BlockInput: If true, input actions should be blocked from
// reaching lower-priority consumers (e.g. the pawn).
//
////////////////////////////////////////////////////////////
USTRUCT(BlueprintType)
struct FlxEventRequest
struct FlxInputModeRequest
{
GENERATED_BODY()
FlxEventRequest() = default;
FlxEventRequest(UUserWidget *InWidget, bool InUseUIOnly, bool InShowPointer, const TArray<FKey> &InHotkeys)
: Widget(InWidget), UseUIOnly(InUseUIOnly), ShowPointer(InShowPointer), Hotkeys(InHotkeys) {}
FlxInputModeRequest() = default;
FlxInputModeRequest(UUserWidget *InWidget, UWidget *InFocus, bool InShowPointer, bool InBlockInput)
: Widget(InWidget), Focus(InFocus), ShowPointer(InShowPointer), BlockInput(InBlockInput) {}
bool operator == (const FlxEventRequest &Other) const;
bool operator == (const FlxInputModeRequest &Other) const;
// True if this request wants any high-priority resource:
// keyboard focus, the mouse pointer, or input blocking.
bool IsHighPrio() const { return Focus != nullptr || ShowPointer || BlockInput; }
UPROPERTY(BlueprintReadWrite)
UUserWidget* Widget = nullptr;
UPROPERTY(BlueprintReadWrite)
bool UseUIOnly = false;
UWidget* Focus = nullptr;
UPROPERTY(BlueprintReadWrite)
bool ShowPointer = false;
UPROPERTY(BlueprintReadWrite)
TArray<FKey> Hotkeys;
bool BlockInput = false;
};
USTRUCT()
struct FlxEventRequests
struct FlxInputModeRequests
{
GENERATED_BODY()
@@ -64,27 +67,29 @@ private:
UPROPERTY()
// High priority requests are always before low-priority.
// Otherwise, these are in order of most recent first.
TArray<FlxEventRequest> Requests;
TArray<FlxInputModeRequest> Requests;
UPROPERTY()
bool Dirty = true;
public:
enum class InputMode { UIOnly, GameOnly };
using View = TArrayView<FlxEventRequest>;
using View = TArrayView<FlxInputModeRequest>;
// Get the requests array.
const TArray<FlxEventRequest> &GetRequests() const { return Requests; }
const TArray<FlxInputModeRequest> &GetRequests() const { return Requests; }
// Sanity check a request to see if it is reasonable.
static bool SanityCheck(const FlxEventRequest &Request);
static bool SanityCheck(const FlxInputModeRequest &Request);
// Divide Requests into a high-priority slice and a low-priority slice.
void SplitHighLow(View &High, View &Low);
// Apply a request. Replaces any previous request by the same widget.
void Request(const FlxEventRequest &NewRequest);
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);
// Remove all requests by the specified widget.
void Remove(UUserWidget *Widget);
@@ -100,7 +105,4 @@ public:
// Set the dirty flag.
void SetDirty() { Dirty = true; }
// Get the currently-requested mode.
InputMode GetRequestedMode() const;
};

View File

@@ -172,6 +172,7 @@ void ALuprexGameModeBase::OnWorldPostActorTick(UWorld* InWorld, ELevelTick InLev
if (PC != nullptr)
{
PC->UpdateLookAt();
PC->UpdateInputMode();
}
}
}

View File

@@ -2,10 +2,14 @@
#include "Common.h"
#include "Tangible.h"
#include "TangibleManager.h"
#include "Blueprint/UserWidget.h"
#include "Components/InputComponent.h"
#include "Engine/LevelScriptActor.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/GameInstance.h"
#include "Engine/GameViewportClient.h"
#include "Engine/LevelScriptActor.h"
#include "Engine/LocalPlayer.h"
#include "Kismet/GameplayStatics.h"
#include "Widgets/SViewport.h"
AlxPlayerControllerBase *AlxPlayerControllerBase::FromContext(const UObject *Context)
{
@@ -55,10 +59,53 @@ FVector2D AlxPlayerControllerBase::GetLookAtPixel(const UObject *Context)
return ScreenPosition;
}
UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *Widget)
{
if (!IsValid(Widget)) return nullptr;
// Cache the FProperty on first call. FProperties are owned by
// the native UUserWidget UClass, which lives for the process
// lifetime, so the pointer stays valid without us needing to
// root anything against GC. Static local init is thread-safe.
static FObjectProperty *InputComponentProp = FindFProperty<FObjectProperty>(
UUserWidget::StaticClass(), TEXT("InputComponent"));
check(InputComponentProp);
UObject *Value = InputComponentProp->GetObjectPropertyValue_InContainer(Widget);
return Cast<UInputComponent>(Value);
}
void AlxPlayerControllerBase::WidgetRequestInputMode(UUserWidget *Widget, bool ShowPointer, bool BlockInput, UWidget *Focus)
{
if (!IsValid(Widget))
{
UE_LOG(LogLuprexIntegration, Error, TEXT("WidgetRequestInputMode called with an invalid widget."));
return;
}
APlayerController *OwningPC = Widget->GetOwningPlayer();
AlxPlayerControllerBase *PC = Cast<AlxPlayerControllerBase>(OwningPC);
if (PC == nullptr)
{
UE_LOG(LogLuprexIntegration, Error,
TEXT("WidgetRequestInputMode: widget '%s' owning player is not an AlxPlayerControllerBase (got %s)."),
*Widget->GetName(), *GetNameSafe(OwningPC));
return;
}
PC->InputModeRequests.Request(FlxInputModeRequest(Widget, Focus, ShowPointer, BlockInput));
}
void AlxPlayerControllerBase::PushInputComponent(UInputComponent* InInputComponent)
{
if (InInputComponent)
{
// Widgets don't go on the current input stack. Instead, they're
// tracked in InputModeRequests, where the PlayerController can arbitrate
// focus, pointer visibility, and input blocking across them.
if (UUserWidget *Widget = Cast<UUserWidget>(InInputComponent->GetOuter()))
{
InputModeRequests.EnsureWidget(Widget);
return;
}
CurrentInputStack.RemoveSingle(InInputComponent);
CurrentInputStack.Add(InInputComponent);
}
@@ -121,22 +168,17 @@ void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputSta
InputStack.Push(InputComponent);
}
// Sort the components first by bBlockInput, then by
// priority. We don't touch the original array, because
// we want to preserve information about which component
// was pushed last.
// The current input stack is unlikely to have anything,
// given that we've moved widgets to their own mechanism.
// But, if there's anything here, sort it and push it.
if (!CurrentInputStack.IsEmpty())
{
TArray<UInputComponent*, TInlineAllocator<20>> Pushed;
for (int32 Idx = 0; Idx < CurrentInputStack.Num(); ++Idx)
{
UInputComponent* IC = CurrentInputStack[Idx].Get();
if (IsValid(IC))
{
Pushed.Add(IC);
}
else
{
CurrentInputStack.RemoveAt(Idx--);
}
if (IsValid(IC)) Pushed.Add(IC);
else CurrentInputStack.RemoveAt(Idx--);
}
Pushed.StableSort([](const UInputComponent& A, const UInputComponent& B)
@@ -146,6 +188,83 @@ void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputSta
});
InputStack.Append(Pushed);
}
// Push the widget input components from InputModeRequests. Requests
// are ordered high-priority first; the input stack is processed
// from top (last index) down, so we push back-to-front: low
// priority first, high priority last (ending up on top).
const TArray<FlxInputModeRequest> &Requests = InputModeRequests.GetRequests();
for (int32 Idx = Requests.Num() - 1; Idx >= 0; --Idx)
{
if (UInputComponent *IC = GetWidgetInputComponent(Requests[Idx].Widget))
{
IC->bBlockInput = Requests[Idx].BlockInput;
InputStack.Push(IC);
}
}
}
void AlxPlayerControllerBase::UpdateInputMode()
{
InputModeRequests.GarbageCollect();
// Get all the various objects we need to be able to manipulate
// the input mode.
UGameViewportClient *GameViewportClient = GetWorld()->GetGameViewport();
ULocalPlayer *LocalPlayer = Cast<ULocalPlayer>(Player);
if (GameViewportClient == nullptr || LocalPlayer == nullptr) return;
TSharedPtr<SViewport> ViewportWidget = GameViewportClient->GetGameViewportWidget();
if (!ViewportWidget.IsValid()) return;
TSharedRef<SViewport> ViewportWidgetRef = ViewportWidget.ToSharedRef();
FReply &SlateOperations = LocalPlayer->GetSlateOperations();
// The first entry in InputModeRequests (highest priority) dictates the
// pointer / capture / focus state. If there are no requests at all,
// fall back to a static default-constructed request.
static const FlxInputModeRequest EmptyRequest;
const TArray<FlxInputModeRequest> &Requests = InputModeRequests.GetRequests();
const FlxInputModeRequest &Top = Requests.IsEmpty() ? EmptyRequest : Requests[0];
SetShowMouseCursor(Top.ShowPointer);
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();
SlateOperations.ReleaseMouseCapture();
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.
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())
{
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
}
else
{
SlateOperations.SetUserFocus(ViewportWidgetRef);
}
GameViewportClient->SetIgnoreInput(false);
}
void AlxPlayerControllerBase::UpdateLookAt()

View File

@@ -3,6 +3,7 @@
#include "CoreMinimal.h"
#include "Engine/HitResult.h"
#include "GameFramework/PlayerController.h"
#include "InputEvents.h"
#include "PlayerControllerBase.generated.h"
UCLASS(BlueprintType, Blueprintable)
@@ -36,16 +37,35 @@ public:
// Called by GameMode each tick.
void UpdateLookAt();
// Called by GameMode each tick. GCs dead requests and will
// eventually reconcile focus, pointer, and capture state.
void UpdateInputMode();
// Input stack overrides: unsorted, append-on-push.
virtual void PushInputComponent(UInputComponent* InInputComponent) override;
virtual bool PopInputComponent(UInputComponent* InInputComponent) override;
virtual void BuildInputStack(TArray<UInputComponent*>& InputStack) override;
// Read UUserWidget::InputComponent via reflection. The field is
// protected and has no public accessor; this reaches through the
// FProperty so we always see the current value without caching it.
static class UInputComponent* GetWidgetInputComponent(class UUserWidget *Widget);
// Blueprint-facing entry point. Looks like a method on UUserWidget
// (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);
// Get the player controller, cast to AlxPlayerControllerBase.
static AlxPlayerControllerBase *FromContext(const UObject *Context);
UPROPERTY()
FHitResult CurrentLookAt;
UPROPERTY()
FlxInputModeRequests InputModeRequests;
bool MustCallLookAtChanged = false;
};