Display-vs-value custom dropdown options + UI mode parity docs
Extend the EditableInterface with two new BlueprintNativeEvents so property combos can show human-friendly labels while storing technical values: - GetCustomOptionsWithValuesForProperty returns TArray<FPS_Editor_PropertyOption> where each pair carries a DisplayName (shown in the combo) and a Value (written to the property via ImportText). Wins over the legacy flat-string variant when non-empty. Ideal for TSubclassOf / TSoftClassPtr properties driven by a DataTable lookup where the stored value is a class path. - GetCurrentDisplayForProperty lets the actor authoritatively resolve the DisplayName from the current stored value, for cases where the automatic reverse lookup (normalize class path, exact-match) isn't reliable — e.g. several Values collapse to the same label, or the format is user-specific. UI refresh now asks the actor first, then falls back to a normalized match (strips the `TypeName'...'` wrapper from ExportText output so it compares apples to apples with a plain /Game/Path.X_C string). Same logic duplicated across both widget implementations (MainWidget and MainWidget_Legacy) since the project can switch UI mode at runtime. The new CLAUDE.md rule makes that requirement explicit. Also documented in CLAUDE.md: - Editor-vs-gameplay execution boundary: which interface methods fire where, including the gotcha that OnEditorPropertyChanged *does* fire in gameplay on the server when the timeline animates a property. - UI modes: keep all MainWidget* implementations in lockstep to avoid the "I edited the file but nothing changed" class of bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
CLAUDE.md
62
CLAUDE.md
@@ -10,6 +10,68 @@ Unreal Engine 5.5 project with runtime editor plugin (PS_Editor). The plugin ena
|
|||||||
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
|
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
|
||||||
- **Input**: Enhanced Input system (AZERTY: arrows + A/Q movement, E/R/T modes)
|
- **Input**: Enhanced Input system (AZERTY: arrows + A/Q movement, E/R/T modes)
|
||||||
|
|
||||||
|
## Editor vs. Gameplay execution boundary — CRITICAL
|
||||||
|
|
||||||
|
The plugin runs in two distinct modes and it's essential never to leak editor-only code
|
||||||
|
into the gameplay runtime loaded via `UPS_Editor_SceneLoader::LoadScene` (standalone).
|
||||||
|
|
||||||
|
### Editor runtime mode
|
||||||
|
Activated when the `APS_Editor_PlayerController` + `APS_Editor_HUD` + `UPS_Editor_MainWidget*`
|
||||||
|
stack is created (typical during authoring — the user is in the runtime editor).
|
||||||
|
- `UPS_Editor_SceneLoader::bIsEditorModeActive == true`
|
||||||
|
- The HUD widget drives property editing, catalogue spawn, save/load, simulate, timeline
|
||||||
|
- Interface methods called **only** by the widget (never in gameplay):
|
||||||
|
- `GetCustomOptionsForProperty` / `GetCustomOptionsWithValuesForProperty` / `GetCurrentDisplayForProperty`
|
||||||
|
- `OnEditorModeChanged`, `OnEditorTick`
|
||||||
|
- `OnEditorNeedsReinit`, `OnEditorNeedsRespawn`
|
||||||
|
|
||||||
|
### Gameplay mode
|
||||||
|
The project (PROSERVE) loads a scenario via `UPS_Editor_SceneLoader::LoadScene` from a GameMode
|
||||||
|
BeginPlay, without ever instantiating the editor PlayerController / HUD / widget.
|
||||||
|
- `UPS_Editor_SceneLoader::bIsEditorModeActive == false`
|
||||||
|
- Actors spawn, their properties are applied from JSON, `OnPropertiesLoaded` fires once
|
||||||
|
- The timeline subsystem auto-plays if the scenario has tracks, driven **server-side only**
|
||||||
|
|
||||||
|
### Interface methods that fire in BOTH modes
|
||||||
|
- `OnPropertiesLoaded` — after every scene load, editor or gameplay (applied once post-spawn)
|
||||||
|
- `OnEditorPropertyChanged` — fires both in editor (on UI edit via `ApplyPropertyFromUI`) AND
|
||||||
|
in gameplay (on server when the timeline applies a new keyframe value via
|
||||||
|
`TimelineSubsystem::ApplyTrackAtTime`). Despite the name, the "Editor" refers to the
|
||||||
|
PS_Editor plugin — the event is the canonical hook for "a property changed at runtime".
|
||||||
|
In gameplay only the **server** invokes it; client-side reaction must go through
|
||||||
|
`ReplicatedUsing=OnRep_X` RepNotify on the replicated property.
|
||||||
|
|
||||||
|
### Rule when adding new features
|
||||||
|
- Anything tied to property panel UI (new interface method, new widget logic, dropdown
|
||||||
|
helpers, etc.) lives in the widget only — no extra work to scope it out of gameplay
|
||||||
|
- Anything added to the `SceneLoader::LoadScene` path runs in gameplay and must handle
|
||||||
|
`NM_Client`, replication, and server-authority correctly (the timeline subsystem is the
|
||||||
|
reference pattern)
|
||||||
|
- Expensive BP implementations of editor-UI interface methods (DataTable lookups, class
|
||||||
|
resolution, etc.) can stay expensive — they never run outside the authoring session
|
||||||
|
|
||||||
|
### How to guard code that could run in either mode
|
||||||
|
```cpp
|
||||||
|
if (UPS_Editor_SceneLoader::IsInEditorMode())
|
||||||
|
{
|
||||||
|
// editor-only behaviour
|
||||||
|
}
|
||||||
|
```
|
||||||
|
or gate on `bIsCurrentlyLoading` to skip BP init during JSON restore.
|
||||||
|
|
||||||
|
## UI Modes — IMPORTANT for widget changes
|
||||||
|
|
||||||
|
The runtime editor ships with **multiple widget implementations** that must all stay in sync:
|
||||||
|
- `PS_Editor_MainWidget.cpp/.h` — modern UMG-based widget (primary target long-term)
|
||||||
|
- `PS_Editor_MainWidget_Legacy.cpp/.h` — Slate-only legacy widget (**currently active** as of April 2026)
|
||||||
|
- `PS_Editor_MainWidget_UMG.cpp/.h` — UMG variant
|
||||||
|
|
||||||
|
The user can switch between them via a `UIMode` setting. At any given time only one is running, but the project keeps all three compiled.
|
||||||
|
|
||||||
|
**Rule:** when changing any behavior inside a widget (new interface calls, new property row logic, new UI controls, bug fixes in RebuildDynamicProperties / ApplyPropertyFromUI / RefreshDynamicPropertyValues, etc.), **apply the change to ALL widget implementations at the same time**. Otherwise the user's current UIMode gets the fix and the others don't — the "it's like you didn't change anything" bug.
|
||||||
|
|
||||||
|
Symptom that the wrong widget was edited: logs you added don't appear in the Output Log despite the build being clean and the DLL being correctly loaded. Always grep for the function name across all `PS_Editor_MainWidget*.cpp` files before assuming a single edit is sufficient.
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
|
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
|
||||||
- Code and comments in English
|
- Code and comments in English
|
||||||
|
|||||||
@@ -4,6 +4,30 @@
|
|||||||
#include "UObject/Interface.h"
|
#include "UObject/Interface.h"
|
||||||
#include "PS_Editor_EditableInterface.generated.h"
|
#include "PS_Editor_EditableInterface.generated.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in a property's custom dropdown: what the user sees vs. what gets
|
||||||
|
* written into the property. Useful when the stored value is a class path, a tag,
|
||||||
|
* or any technical identifier and you want a human-friendly label in the combo.
|
||||||
|
*/
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct FPS_Editor_PropertyOption
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
/** Label shown in the runtime editor combo box. */
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||||
|
FString DisplayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value written into the property when this option is selected (passed through
|
||||||
|
* FProperty::ImportText_InContainer). For a class-name FString, this would be
|
||||||
|
* the full class path; for an enum you'd use the NameString; for a free FString,
|
||||||
|
* any value you want stored.
|
||||||
|
*/
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||||
|
FString Value;
|
||||||
|
};
|
||||||
|
|
||||||
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
|
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
|
||||||
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
|
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
|
||||||
{
|
{
|
||||||
@@ -69,12 +93,49 @@ public:
|
|||||||
void OnEditorTick(float DeltaTime);
|
void OnEditorTick(float DeltaTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return custom dropdown options for a given property.
|
* Return custom dropdown options for a given property (display == stored value).
|
||||||
* If the returned array is non-empty, the editor shows a combo box with these options
|
* Simple form: the string shown in the combo is also the value written to the property.
|
||||||
* instead of the default widget for that property type.
|
* Use GetCustomOptionsWithValuesForProperty when display and storage need to differ.
|
||||||
|
*
|
||||||
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
|
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
|
||||||
* @return List of option strings. Empty = use default widget.
|
* @return List of option strings. Empty = use default widget / fall through to the
|
||||||
|
* structured variant below.
|
||||||
*/
|
*/
|
||||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
TArray<FString> GetCustomOptionsForProperty(const FString& PropertyPath) const;
|
TArray<FString> GetCustomOptionsForProperty(const FString& PropertyPath) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured variant of GetCustomOptionsForProperty: each entry carries a separate
|
||||||
|
* DisplayName (shown in the combo) and Value (written to the property). This is the
|
||||||
|
* version to use when the stored variable is a class name, a tag, a technical id —
|
||||||
|
* anything the user shouldn't have to read or type.
|
||||||
|
*
|
||||||
|
* Priority: if this returns a non-empty array, it wins over GetCustomOptionsForProperty.
|
||||||
|
*
|
||||||
|
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
|
||||||
|
* @return List of display/value pairs. Empty = fall back to the flat-string variant.
|
||||||
|
*/
|
||||||
|
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
|
TArray<FPS_Editor_PropertyOption> GetCustomOptionsWithValuesForProperty(const FString& PropertyPath) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the DisplayName that should appear in the combo for the CURRENT value of the
|
||||||
|
* property. Use this when the runtime editor can't reliably reverse-lookup the display
|
||||||
|
* from the raw stored value (e.g. several Values collapse to the same DisplayName, or
|
||||||
|
* the Value format doesn't trivially match what you stored).
|
||||||
|
*
|
||||||
|
* The runtime editor calls this each frame while refreshing a row that has a custom-
|
||||||
|
* options combo. Typical Blueprint implementation: switch on PropertyPath, resolve the
|
||||||
|
* current variable (e.g. cast Self to your actor and read the Weapon variable), then
|
||||||
|
* search your DataTable / lookup for the matching DisplayName and return it.
|
||||||
|
*
|
||||||
|
* Return an empty string to let the default resolution kick in (exact match then
|
||||||
|
* normalized class-path match against the options returned by
|
||||||
|
* GetCustomOptionsWithValuesForProperty).
|
||||||
|
*
|
||||||
|
* @param PropertyPath The property path (e.g. "Weapon" or "MyComponent.WeaponType").
|
||||||
|
* @return DisplayName to show in the combo, or empty string to fall back.
|
||||||
|
*/
|
||||||
|
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
|
FString GetCurrentDisplayForProperty(const FString& PropertyPath) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2400,11 +2400,26 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
|||||||
// Value widget depends on property type
|
// Value widget depends on property type
|
||||||
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
||||||
|
|
||||||
// Check for custom dropdown options from the interface
|
// Check for custom dropdown options from the interface. Structured variant
|
||||||
|
// (display + value) wins; fall back to the flat-string variant if it returns empty.
|
||||||
TArray<FString> CustomOptions;
|
TArray<FString> CustomOptions;
|
||||||
|
Row.CustomOptionDisplayToValue.Empty();
|
||||||
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
||||||
{
|
{
|
||||||
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
TArray<FPS_Editor_PropertyOption> CustomOptionsPairs =
|
||||||
|
IPS_Editor_EditableInterface::Execute_GetCustomOptionsWithValuesForProperty(Actor, Row.Key);
|
||||||
|
if (CustomOptionsPairs.Num() > 0)
|
||||||
|
{
|
||||||
|
for (const FPS_Editor_PropertyOption& Pair : CustomOptionsPairs)
|
||||||
|
{
|
||||||
|
CustomOptions.Add(Pair.DisplayName);
|
||||||
|
Row.CustomOptionDisplayToValue.Add(Pair.DisplayName, Pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CustomOptions.Num() > 0)
|
if (CustomOptions.Num() > 0)
|
||||||
@@ -2648,17 +2663,52 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
|
|||||||
FString CurrentVal;
|
FString CurrentVal;
|
||||||
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
||||||
|
|
||||||
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != CurrentVal)
|
// Resolve the DisplayName to show in the combo. Priority order:
|
||||||
|
// 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows
|
||||||
|
// their storage format best, so this is authoritative when overridden.
|
||||||
|
// 2. Otherwise, fall back to matching CurrentVal / normalized-path against
|
||||||
|
// the options returned by GetCustomOptionsWithValuesForProperty.
|
||||||
|
FString DisplayToShow = CurrentVal;
|
||||||
|
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
||||||
|
{
|
||||||
|
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
|
||||||
|
if (!FromActor.IsEmpty())
|
||||||
|
{
|
||||||
|
DisplayToShow = FromActor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0)
|
||||||
|
{
|
||||||
|
// Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`.
|
||||||
|
FString NormalizedCurrent = CurrentVal;
|
||||||
|
int32 FirstQuote = INDEX_NONE;
|
||||||
|
int32 LastQuote = INDEX_NONE;
|
||||||
|
if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote)
|
||||||
|
&& LastQuote > FirstQuote + 1)
|
||||||
|
{
|
||||||
|
NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1);
|
||||||
|
}
|
||||||
|
for (const TPair<FString, FString>& P : Row.CustomOptionDisplayToValue)
|
||||||
|
{
|
||||||
|
if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent))
|
||||||
|
{
|
||||||
|
DisplayToShow = P.Key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow)
|
||||||
{
|
{
|
||||||
// Find matching option
|
// Find matching option
|
||||||
if (Row.EnumOptions.IsValid())
|
if (Row.EnumOptions.IsValid())
|
||||||
{
|
{
|
||||||
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
||||||
{
|
{
|
||||||
if (*Opt == CurrentVal)
|
if (*Opt == DisplayToShow)
|
||||||
{
|
{
|
||||||
Row.EnumCombo->SetSelectedItem(Opt);
|
Row.EnumCombo->SetSelectedItem(Opt);
|
||||||
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
|
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2804,8 +2854,25 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
|
|||||||
|
|
||||||
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
// Custom options combo: use selected text directly as property value
|
// Custom options combo. If we have a display->value map (structured variant),
|
||||||
NewValueText = Row.EnumComboText->GetText().ToString();
|
// translate the selected display name into its stored value; otherwise fall back
|
||||||
|
// to the flat-string behavior where display and value are the same.
|
||||||
|
const FString Display = Row.EnumComboText->GetText().ToString();
|
||||||
|
if (Row.CustomOptionDisplayToValue.Num() > 0)
|
||||||
|
{
|
||||||
|
if (const FString* Mapped = Row.CustomOptionDisplayToValue.Find(Display))
|
||||||
|
{
|
||||||
|
NewValueText = *Mapped;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewValueText = Display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewValueText = Display;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ struct FPS_Editor_PropertyRowWidgets
|
|||||||
TSharedPtr<STextBlock> EnumComboText;
|
TSharedPtr<STextBlock> EnumComboText;
|
||||||
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
||||||
UEnum* CachedEnum = nullptr;
|
UEnum* CachedEnum = nullptr;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DisplayName -> stored Value mapping for rows whose combo was populated by
|
||||||
|
* GetCustomOptionsWithValuesForProperty. Empty when the row is a flat-string combo
|
||||||
|
* (display == value) or a native enum combo.
|
||||||
|
*/
|
||||||
|
TMap<FString, FString> CustomOptionDisplayToValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2063,11 +2063,26 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
|||||||
// Value widget depends on property type
|
// Value widget depends on property type
|
||||||
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
||||||
|
|
||||||
// Check for custom dropdown options from the interface
|
// Check for custom dropdown options from the interface. Structured variant
|
||||||
|
// (display + value) wins; fall back to the flat-string variant if it returns empty.
|
||||||
TArray<FString> CustomOptions;
|
TArray<FString> CustomOptions;
|
||||||
|
Row.CustomOptionDisplayToValue.Empty();
|
||||||
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
||||||
{
|
{
|
||||||
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
TArray<FPS_Editor_PropertyOption> CustomOptionsPairs =
|
||||||
|
IPS_Editor_EditableInterface::Execute_GetCustomOptionsWithValuesForProperty(Actor, Row.Key);
|
||||||
|
if (CustomOptionsPairs.Num() > 0)
|
||||||
|
{
|
||||||
|
for (const FPS_Editor_PropertyOption& Pair : CustomOptionsPairs)
|
||||||
|
{
|
||||||
|
CustomOptions.Add(Pair.DisplayName);
|
||||||
|
Row.CustomOptionDisplayToValue.Add(Pair.DisplayName, Pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CustomOptions.Num() > 0)
|
if (CustomOptions.Num() > 0)
|
||||||
@@ -2307,17 +2322,52 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
|
|||||||
FString CurrentVal;
|
FString CurrentVal;
|
||||||
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
||||||
|
|
||||||
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != CurrentVal)
|
// Resolve the DisplayName to show in the combo. Priority order:
|
||||||
|
// 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows
|
||||||
|
// their storage format best, so this is authoritative when overridden.
|
||||||
|
// 2. Otherwise, fall back to matching CurrentVal / normalized-path against
|
||||||
|
// the options returned by GetCustomOptionsWithValuesForProperty.
|
||||||
|
FString DisplayToShow = CurrentVal;
|
||||||
|
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
||||||
|
{
|
||||||
|
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
|
||||||
|
if (!FromActor.IsEmpty())
|
||||||
|
{
|
||||||
|
DisplayToShow = FromActor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0)
|
||||||
|
{
|
||||||
|
// Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`.
|
||||||
|
FString NormalizedCurrent = CurrentVal;
|
||||||
|
int32 FirstQuote = INDEX_NONE;
|
||||||
|
int32 LastQuote = INDEX_NONE;
|
||||||
|
if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote)
|
||||||
|
&& LastQuote > FirstQuote + 1)
|
||||||
|
{
|
||||||
|
NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1);
|
||||||
|
}
|
||||||
|
for (const TPair<FString, FString>& P : Row.CustomOptionDisplayToValue)
|
||||||
|
{
|
||||||
|
if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent))
|
||||||
|
{
|
||||||
|
DisplayToShow = P.Key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow)
|
||||||
{
|
{
|
||||||
// Find matching option
|
// Find matching option
|
||||||
if (Row.EnumOptions.IsValid())
|
if (Row.EnumOptions.IsValid())
|
||||||
{
|
{
|
||||||
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
||||||
{
|
{
|
||||||
if (*Opt == CurrentVal)
|
if (*Opt == DisplayToShow)
|
||||||
{
|
{
|
||||||
Row.EnumCombo->SetSelectedItem(Opt);
|
Row.EnumCombo->SetSelectedItem(Opt);
|
||||||
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
|
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2463,8 +2513,25 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
|
|||||||
|
|
||||||
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
// Custom options combo: use selected text directly as property value
|
// Custom options combo. If we have a display->value map (structured variant),
|
||||||
NewValueText = Row.EnumComboText->GetText().ToString();
|
// translate the selected display name into its stored value; otherwise fall back
|
||||||
|
// to the flat-string behavior where display and value are the same.
|
||||||
|
const FString Display = Row.EnumComboText->GetText().ToString();
|
||||||
|
if (Row.CustomOptionDisplayToValue.Num() > 0)
|
||||||
|
{
|
||||||
|
if (const FString* Mapped = Row.CustomOptionDisplayToValue.Find(Display))
|
||||||
|
{
|
||||||
|
NewValueText = *Mapped;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewValueText = Display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NewValueText = Display;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ struct FPS_Editor_PropertyRowWidgetsLegacy
|
|||||||
TSharedPtr<STextBlock> EnumComboText;
|
TSharedPtr<STextBlock> EnumComboText;
|
||||||
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
||||||
UEnum* CachedEnum = nullptr;
|
UEnum* CachedEnum = nullptr;
|
||||||
|
|
||||||
|
/** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */
|
||||||
|
TMap<FString, FString> CustomOptionDisplayToValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user