Add undo/redo system, properties panel, and toolbar UI

Undo/Redo:
- Custom runtime UndoManager (UE transaction system is editor-only)
- Ctrl+Z / Ctrl+Y hotkeys, action stack with 50 history limit
- Records: gizmo translate/rotate/scale, snap to ground, UI edits
- Soft-delete on Delete key (hide actor, undo restores it)
- Gizmo follows restored position on undo/redo

UI:
- Properties panel (right side): editable Location/Rotation/Scale fields
- Real-time updates during gizmo drag, manual input with Enter to apply
- Toolbar: Translate(E)/Rotate(R)/Scale(T) buttons + Snap Ground
- Mode indicator in title bar, updated help text with all shortcuts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 19:11:40 +02:00
parent 20017ff3fd
commit ccb154c44f
10 changed files with 806 additions and 53 deletions

View File

@@ -11,6 +11,7 @@
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "DrawDebugHelpers.h"
APS_Editor_Pawn::APS_Editor_Pawn()
@@ -172,6 +173,15 @@ void APS_Editor_Pawn::CreateInputActions()
IA_SnapToGround = NewObject<UInputAction>(this, TEXT("IA_SnapToGround"));
IA_SnapToGround->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_SnapToGround, EKeys::End);
// Undo/Redo (Ctrl+Z / Ctrl+Y handled via key state check in handler)
IA_Undo = NewObject<UInputAction>(this, TEXT("IA_Undo"));
IA_Undo->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Undo, EKeys::Z);
IA_Redo = NewObject<UInputAction>(this, TEXT("IA_Redo"));
IA_Redo->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Redo, EKeys::Y);
}
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -210,6 +220,8 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
EIC->BindAction(IA_ScaleMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleScaleMode);
EIC->BindAction(IA_Delete, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleDelete);
EIC->BindAction(IA_SnapToGround, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSnapToGround);
EIC->BindAction(IA_Undo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleUndo);
EIC->BindAction(IA_Redo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRedo);
}
// ---- Input Handlers ----
@@ -440,8 +452,48 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
OnEditorClick.ExecuteIfBound(PendingClickScreenPos, bCtrlHeld);
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit
|| CameraMode == EPS_Editor_CameraMode::GizmoDrag)
else if (CameraMode == EPS_Editor_CameraMode::GizmoDrag)
{
// Record undo action for the completed gizmo drag
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
TSharedPtr<FPS_Editor_TransformAction> Action = MakeShared<FPS_Editor_TransformAction>();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
Action->Actors.Add(Actor);
Action->OldTransforms.Add(GizmoDragInitialTransforms[i]);
Action->NewTransforms.Add(Actor->GetActorTransform());
}
}
FString ModeStr;
switch (SM->GetTransformMode())
{
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale"); break;
}
Action->Description = FString::Printf(TEXT("%s %d actor(s)"), *ModeStr, Action->Actors.Num());
if (Action->Actors.Num() > 0)
{
UM->RecordAction(Action);
}
}
}
}
ActiveGizmoAxis = EPS_Editor_GizmoAxis::None;
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
@@ -516,20 +568,52 @@ void APS_Editor_Pawn::HandleScaleMode(const FInputActionValue& Value)
void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
TArray<TWeakObjectPtr<AActor>> ToDelete = SM->GetSelectedActors();
SM->ClearSelection();
for (const TWeakObjectPtr<AActor>& Weak : ToDelete)
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return;
// Soft-delete: hide actors instead of destroying (enables undo)
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
if (AActor* Actor = Weak.Get())
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deleted %s"), *Actor->GetName());
Actor->Destroy();
Action->Actors.Add(Actor);
}
}
SM->ClearSelection();
Action->Execute(); // Hide the actors
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(Action);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Action->Actors.Num());
}
void APS_Editor_Pawn::HandleUndo(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->Undo();
}
}
}
void APS_Editor_Pawn::HandleRedo(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->Redo();
}
}
}

View File

@@ -161,6 +161,12 @@ private:
UPROPERTY()
TObjectPtr<UInputAction> IA_SnapToGround;
UPROPERTY()
TObjectPtr<UInputAction> IA_Undo;
UPROPERTY()
TObjectPtr<UInputAction> IA_Redo;
// ---- Modifier key state ----
bool bAltHeld = false;
bool bCtrlHeld = false;
@@ -191,6 +197,8 @@ private:
void HandleScaleMode(const FInputActionValue& Value);
void HandleDelete(const FInputActionValue& Value);
void HandleSnapToGround(const FInputActionValue& Value);
void HandleUndo(const FInputActionValue& Value);
void HandleRedo(const FInputActionValue& Value);
// ---- Helpers ----
void CreateInputActions();

View File

@@ -1,5 +1,7 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_Pawn.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
@@ -17,6 +19,20 @@ void APS_Editor_PlayerController::BeginPlay()
// Create selection manager
SelectionManager = NewObject<UPS_Editor_SelectionManager>(this);
SelectionManager->Initialize(this);
UndoManager = NewObject<UPS_Editor_UndoManager>(this);
// After undo/redo, refresh the gizmo position
UndoManager->OnUndoRedo.AddWeakLambda(this, [this]()
{
if (SelectionManager && SelectionManager->IsAnythingSelected())
{
if (APS_Editor_Gizmo* Gizmo = SelectionManager->GetGizmo())
{
Gizmo->SetActorLocation(SelectionManager->GetSelectionPivot());
}
}
});
}
void APS_Editor_PlayerController::OnPossess(APawn* InPawn)

View File

@@ -5,6 +5,7 @@
#include "PS_Editor_PlayerController.generated.h"
class UPS_Editor_SelectionManager;
class UPS_Editor_UndoManager;
/**
* Player controller for PS_Editor runtime editing.
@@ -21,6 +22,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SelectionManager* GetSelectionManager() const { return SelectionManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_UndoManager* GetUndoManager() const { return UndoManager; }
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
@@ -29,5 +33,8 @@ private:
UPROPERTY()
TObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
UPROPERTY()
TObjectPtr<UPS_Editor_UndoManager> UndoManager;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -1,5 +1,7 @@
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
@@ -223,17 +225,22 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
APlayerController* PC = OwnerPC.Get();
if (!PC) return;
// Capture transforms BEFORE snap for undo
TSharedPtr<FPS_Editor_TransformAction> UndoAction = MakeShared<FPS_Editor_TransformAction>();
UndoAction->Description = FString::Printf(TEXT("Snap to ground %d actor(s)"), SelectedActors.Num());
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
// Get actor bounds to compute the bottom of the actor
UndoAction->Actors.Add(Actor);
UndoAction->OldTransforms.Add(Actor->GetActorTransform());
FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent);
const float BottomOffset = Extent.Z; // Distance from actor origin to bottom
const float BottomOffset = Extent.Z;
// Trace downward from actor location
const FVector Start = Actor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
@@ -245,12 +252,23 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
if (Actor->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
// Place actor so its bottom sits on the ground
FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Snapped %s to ground"), *Actor->GetName());
UndoAction->NewTransforms.Add(Actor->GetActorTransform());
}
// Record undo action
if (UndoAction->Actors.Num() > 0)
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(UndoAction);
}
}
}

View File

@@ -0,0 +1,105 @@
#include "PS_Editor_UndoManager.h"
void UPS_Editor_UndoManager::RecordAction(TSharedPtr<FPS_Editor_Action> Action)
{
if (!Action.IsValid())
{
return;
}
// Discard any redo history (actions after CurrentIndex)
if (CurrentIndex < ActionStack.Num() - 1)
{
// Cleanup delete actions that will be discarded (truly destroy hidden actors)
for (int32 i = CurrentIndex + 1; i < ActionStack.Num(); ++i)
{
CleanupDeleteAction(ActionStack[i]);
}
ActionStack.SetNum(CurrentIndex + 1);
}
ActionStack.Add(Action);
CurrentIndex = ActionStack.Num() - 1;
PruneHistory();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Recorded action: %s (stack size: %d)"),
*Action->GetDescription(), ActionStack.Num());
}
void UPS_Editor_UndoManager::Undo()
{
if (!CanUndo())
{
return;
}
TSharedPtr<FPS_Editor_Action>& Action = ActionStack[CurrentIndex];
Action->Undo();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Undo: %s"), *Action->GetDescription());
CurrentIndex--;
OnUndoRedo.Broadcast();
}
void UPS_Editor_UndoManager::Redo()
{
if (!CanRedo())
{
return;
}
CurrentIndex++;
TSharedPtr<FPS_Editor_Action>& Action = ActionStack[CurrentIndex];
Action->Execute();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Redo: %s"), *Action->GetDescription());
OnUndoRedo.Broadcast();
}
FString UPS_Editor_UndoManager::GetUndoDescription() const
{
if (!CanUndo())
{
return TEXT("");
}
return ActionStack[CurrentIndex]->GetDescription();
}
FString UPS_Editor_UndoManager::GetRedoDescription() const
{
if (!CanRedo())
{
return TEXT("");
}
return ActionStack[CurrentIndex + 1]->GetDescription();
}
void UPS_Editor_UndoManager::PruneHistory()
{
while (ActionStack.Num() > MaxHistorySize)
{
// Cleanup the oldest action before removing
CleanupDeleteAction(ActionStack[0]);
ActionStack.RemoveAt(0);
CurrentIndex--;
}
}
void UPS_Editor_UndoManager::CleanupDeleteAction(TSharedPtr<FPS_Editor_Action> Action)
{
// If a delete action is being pruned from history, truly destroy the hidden actors
if (!Action.IsValid() || Action->ActionType != EPS_Editor_ActionType::Delete) return;
FPS_Editor_DeleteAction* DeleteAction = static_cast<FPS_Editor_DeleteAction*>(Action.Get());
for (const TWeakObjectPtr<AActor>& Weak : DeleteAction->Actors)
{
if (AActor* Actor = Weak.Get())
{
if (Actor->IsHidden())
{
Actor->Destroy();
}
}
}
}

View File

@@ -0,0 +1,160 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_UndoManager.generated.h"
enum class EPS_Editor_ActionType : uint8
{
Transform,
Delete
};
/**
* Base class for undoable editor actions.
*/
struct FPS_Editor_Action
{
EPS_Editor_ActionType ActionType;
explicit FPS_Editor_Action(EPS_Editor_ActionType InType) : ActionType(InType) {}
virtual ~FPS_Editor_Action() = default;
virtual void Execute() = 0;
virtual void Undo() = 0;
virtual FString GetDescription() const = 0;
};
/**
* Records a transform change on one or more actors.
* Used for: gizmo translate/rotate/scale, snap to ground, UI transform edit.
*/
struct FPS_Editor_TransformAction : public FPS_Editor_Action
{
FPS_Editor_TransformAction() : FPS_Editor_Action(EPS_Editor_ActionType::Transform) {}
TArray<TWeakObjectPtr<AActor>> Actors;
TArray<FTransform> OldTransforms;
TArray<FTransform> NewTransforms;
FString Description;
virtual void Execute() override
{
for (int32 i = 0; i < Actors.Num(); ++i)
{
if (AActor* Actor = Actors[i].Get())
{
if (i < NewTransforms.Num())
{
Actor->SetActorTransform(NewTransforms[i]);
}
}
}
}
virtual void Undo() override
{
for (int32 i = 0; i < Actors.Num(); ++i)
{
if (AActor* Actor = Actors[i].Get())
{
if (i < OldTransforms.Num())
{
Actor->SetActorTransform(OldTransforms[i]);
}
}
}
}
virtual FString GetDescription() const override { return Description; }
};
/**
* Records a soft-delete action (hide + disable collision).
* Undo re-shows the actor. Actors are truly destroyed when the action is pruned from the stack.
*/
struct FPS_Editor_DeleteAction : public FPS_Editor_Action
{
FPS_Editor_DeleteAction() : FPS_Editor_Action(EPS_Editor_ActionType::Delete) {}
TArray<TWeakObjectPtr<AActor>> Actors;
virtual void Execute() override
{
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
}
}
}
virtual void Undo() override
{
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(false);
Actor->SetActorEnableCollision(true);
Actor->SetActorTickEnabled(true);
}
}
}
virtual FString GetDescription() const override
{
return FString::Printf(TEXT("Delete %d actor(s)"), Actors.Num());
}
};
/**
* Runtime undo/redo manager for PS_Editor.
* Maintains a linear action stack with a cursor.
* When a new action is recorded after undoing, all redo history is discarded.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_UndoManager : public UObject
{
GENERATED_BODY()
public:
/** Record a new undoable action. Discards any redo history. */
void RecordAction(TSharedPtr<FPS_Editor_Action> Action);
/** Undo the last action. */
void Undo();
/** Redo the next action. */
void Redo();
bool CanUndo() const { return CurrentIndex >= 0; }
bool CanRedo() const { return CurrentIndex < ActionStack.Num() - 1; }
/** Get description of the action that would be undone. */
FString GetUndoDescription() const;
/** Get description of the action that would be redone. */
FString GetRedoDescription() const;
/** Called after any undo/redo to update gizmo, UI, etc. */
DECLARE_MULTICAST_DELEGATE(FOnUndoRedo);
FOnUndoRedo OnUndoRedo;
/** Maximum number of actions to keep in history. */
int32 MaxHistorySize = 50;
private:
TArray<TSharedPtr<FPS_Editor_Action>> ActionStack;
/** Points to the last executed action. -1 means no actions executed. */
int32 CurrentIndex = -1;
/** Prune oldest actions if stack exceeds MaxHistorySize. */
void PruneHistory();
/** Destroy actors from pruned delete actions. */
void CleanupDeleteAction(TSharedPtr<FPS_Editor_Action> Action);
};

View File

@@ -1,5 +1,7 @@
#include "PS_Editor_HUD.h"
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "Blueprint/UserWidget.h"
void APS_Editor_HUD::BeginPlay()
@@ -11,6 +13,12 @@ void APS_Editor_HUD::BeginPlay()
MainWidget = CreateWidget<UPS_Editor_MainWidget>(PC, UPS_Editor_MainWidget::StaticClass());
if (MainWidget)
{
// Wire the SelectionManager to the UI
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
}
MainWidget->AddToViewport(0);
}
}

View File

@@ -1,22 +1,33 @@
#include "PS_Editor_MainWidget.h"
#include "Components/TextBlock.h"
#include "Components/VerticalBox.h"
#include "Components/VerticalBoxSlot.h"
#include "Components/Border.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{
SelectionManager = InSelectionManager;
}
TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
{
// Build the widget tree using Slate (C++ driven UMG)
return SNew(SOverlay)
// Top bar
// Top-left: Title + Toolbar
+ SOverlay::Slot()
.HAlign(HAlign_Left)
.VAlign(VAlign_Top)
.Padding(16.0f)
[
SNew(SVerticalBox)
// Title bar
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
@@ -37,13 +48,29 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.VAlign(VAlign_Center)
.Padding(12.0f, 0.0f, 0.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Edit Mode")))
SAssignNew(ModeText, STextBlock)
.Text(FText::FromString(TEXT("Translate (E)")))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
]
]
]
// Toolbar
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
BuildToolbar()
]
]
// Right panel: Properties
+ SOverlay::Slot()
.HAlign(HAlign_Right)
.VAlign(VAlign_Top)
.Padding(16.0f)
[
BuildPropertiesPanel()
]
// Bottom help bar
+ SOverlay::Slot()
.HAlign(HAlign_Center)
@@ -55,10 +82,163 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.Padding(FMargin(16.0f, 6.0f))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("LMB: Pan | RMB: Fly (ZQSD) | Alt+LMB / MMB: Orbit | E/A: Up/Down | Scroll: Zoom")))
.Text(FText::FromString(TEXT("LMB: Select/Pan | RMB: Fly | Alt+LMB/MMB: Orbit | E/R/T: Translate/Rotate/Scale | End: Snap | Del: Delete")))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
{
auto MakeButton = [this](const FText& Label, EPS_Editor_TransformMode Mode) -> TSharedRef<SWidget>
{
return SNew(SButton)
.OnClicked_Lambda([this, Mode]() -> FReply
{
OnModeButtonClicked(Mode);
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(Label)
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
.Justification(ETextJustify::Center)
];
};
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(8.0f, 4.0f))
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Translate (E)")), EPS_Editor_TransformMode::Translate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Rotate (R)")), EPS_Editor_TransformMode::Rotate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Scale (T)")), EPS_Editor_TransformMode::Scale) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->SnapSelectionToGround();
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Snap Ground")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildPropertiesPanel()
{
const FMargin RowPadding(0.0f, 2.0f);
const FSlateFontInfo LabelFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
const FSlateFontInfo ValueFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
const float LabelWidth = 14.0f;
const float FieldWidth = 70.0f;
auto MakeRow = [&](const FText& Label, TSharedPtr<SEditableTextBox>& X, TSharedPtr<SEditableTextBox>& Y, TSharedPtr<SEditableTextBox>& Z, const FLinearColor& ColorX, const FLinearColor& ColorY, const FLinearColor& ColorZ) -> TSharedRef<SWidget>
{
auto MakeField = [&](TSharedPtr<SEditableTextBox>& Field, const FLinearColor& Col) -> TSharedRef<SWidget>
{
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(4.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SBorder)
.BorderBackgroundColor(Col)
.Padding(FMargin(3.0f, 1.0f))
[
SNew(STextBlock)
.Font(LabelFont)
.ColorAndOpacity(FLinearColor::White)
]
]
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SAssignNew(Field, SEditableTextBox)
.Font(ValueFont)
.MinDesiredWidth(FieldWidth)
.OnTextCommitted_Lambda([this](const FText&, ETextCommit::Type)
{
ApplyTransformFromUI();
})
];
};
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(Label)
.Font(LabelFont)
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.MinDesiredWidth(LabelWidth)
]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(X, ColorX) ]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(Y, ColorY) ]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(Z, ColorZ) ];
};
const FLinearColor RedX(0.8f, 0.15f, 0.1f);
const FLinearColor GreenY(0.15f, 0.6f, 0.1f);
const FLinearColor BlueZ(0.1f, 0.3f, 0.8f);
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(10.0f, 8.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f)
[
SAssignNew(SelectionInfoText, STextBlock)
.Text(FText::FromString(TEXT("No selection")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
]
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Location")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), LocX, LocY, LocZ, RedX, GreenY, BlueZ) ]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Rotation")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), RotX, RotY, RotZ, RedX, GreenY, BlueZ) ]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Scale")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), ScaX, ScaY, ScaZ, RedX, GreenY, BlueZ) ]
];
}
@@ -66,3 +246,142 @@ void UPS_Editor_MainWidget::NativeConstruct()
{
Super::NativeConstruct();
}
void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
RefreshPropertiesFromSelection();
}
void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
{
bIsRefreshing = true;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM)
{
bIsRefreshing = false;
return;
}
// Update mode text
if (ModeText.IsValid())
{
FString ModeStr;
switch (SM->GetTransformMode())
{
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate (E)"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate (R)"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale (T)"); break;
}
ModeText->SetText(FText::FromString(ModeStr));
}
if (!SM->IsAnythingSelected())
{
if (SelectionInfoText.IsValid())
{
SelectionInfoText->SetText(FText::FromString(TEXT("No selection")));
}
auto ClearField = [](TSharedPtr<SEditableTextBox>& Field)
{
if (Field.IsValid()) Field->SetText(FText::FromString(TEXT("--")));
};
ClearField(LocX); ClearField(LocY); ClearField(LocZ);
ClearField(RotX); ClearField(RotY); ClearField(RotZ);
ClearField(ScaX); ClearField(ScaY); ClearField(ScaZ);
bIsRefreshing = false;
return;
}
// Show first selected actor info
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
AActor* Actor = Selected[0].Get();
if (!Actor)
{
bIsRefreshing = false;
return;
}
if (SelectionInfoText.IsValid())
{
FString Info = FString::Printf(TEXT("%s (%d)"), *Actor->GetActorLabel(), Selected.Num());
SelectionInfoText->SetText(FText::FromString(Info));
}
const FVector Loc = Actor->GetActorLocation();
const FRotator Rot = Actor->GetActorRotation();
const FVector Sca = Actor->GetActorScale3D();
auto SetField = [this](TSharedPtr<SEditableTextBox>& Field, float Val)
{
if (Field.IsValid() && !Field->HasKeyboardFocus())
{
Field->SetText(FText::FromString(FloatToString(Val)));
}
};
SetField(LocX, Loc.X); SetField(LocY, Loc.Y); SetField(LocZ, Loc.Z);
SetField(RotX, Rot.Roll); SetField(RotY, Rot.Pitch); SetField(RotZ, Rot.Yaw);
SetField(ScaX, Sca.X); SetField(ScaY, Sca.Y); SetField(ScaZ, Sca.Z);
bIsRefreshing = false;
}
void UPS_Editor_MainWidget::ApplyTransformFromUI()
{
if (bIsRefreshing) return;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
AActor* Actor = SM->GetSelectedActors()[0].Get();
if (!Actor) return;
auto GetFloat = [](TSharedPtr<SEditableTextBox>& Field, float Default) -> float
{
if (!Field.IsValid()) return Default;
return FCString::Atof(*Field->GetText().ToString());
};
// Capture old transform for undo
const FTransform OldTransform = Actor->GetActorTransform();
const FVector NewLoc(GetFloat(LocX, 0), GetFloat(LocY, 0), GetFloat(LocZ, 0));
const FRotator NewRot(GetFloat(RotY, 0), GetFloat(RotZ, 0), GetFloat(RotX, 0));
const FVector NewScale(GetFloat(ScaX, 1), GetFloat(ScaY, 1), GetFloat(ScaZ, 1));
Actor->SetActorLocation(NewLoc);
Actor->SetActorRotation(NewRot);
Actor->SetActorScale3D(NewScale);
// Record undo action
if (APlayerController* PC = GetOwningPlayer())
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_TransformAction> Action = MakeShared<FPS_Editor_TransformAction>();
Action->Actors.Add(Actor);
Action->OldTransforms.Add(OldTransform);
Action->NewTransforms.Add(Actor->GetActorTransform());
Action->Description = TEXT("UI Transform Edit");
UM->RecordAction(Action);
}
}
}
}
FString UPS_Editor_MainWidget::FloatToString(float Value) const
{
return FString::Printf(TEXT("%.2f"), Value);
}
void UPS_Editor_MainWidget::OnModeButtonClicked(EPS_Editor_TransformMode Mode)
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->SetTransformMode(Mode);
}
}

View File

@@ -2,24 +2,52 @@
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_MainWidget.generated.h"
class UTextBlock;
class UVerticalBox;
class UBorder;
class UPS_Editor_SelectionManager;
/**
* Main editor widget for PS_Editor.
* Serves as the root container for all editor UI panels.
* Built entirely in C++ (no UMG blueprint).
* Contains: title bar, transform mode toolbar, properties panel, help bar.
* Built entirely in C++ using Slate.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_MainWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** Set the selection manager reference. Must be called after construction. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager);
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void NativeConstruct() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
// ---- Slate widget refs for dynamic updates ----
TSharedPtr<STextBlock> ModeText;
TSharedPtr<STextBlock> SelectionInfoText;
// Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
TSharedPtr<SEditableTextBox> ScaX, ScaY, ScaZ;
// ---- Helpers ----
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
void RefreshPropertiesFromSelection();
void ApplyTransformFromUI();
FString FloatToString(float Value) const;
void OnModeButtonClicked(EPS_Editor_TransformMode Mode);
/** Whether we are currently refreshing UI from selection (avoid feedback loop). */
bool bIsRefreshing = false;
};