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:
2026-04-22 12:43:40 +02:00
parent b069c20af3
commit bc491b7b94
6 changed files with 285 additions and 18 deletions

View File

@@ -4,6 +4,30 @@
#include "UObject/Interface.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"))
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
{
@@ -69,12 +93,49 @@ public:
void OnEditorTick(float DeltaTime);
/**
* Return custom dropdown options for a given property.
* If the returned array is non-empty, the editor shows a combo box with these options
* instead of the default widget for that property type.
* Return custom dropdown options for a given property (display == stored value).
* Simple form: the string shown in the combo is also the value written to the property.
* Use GetCustomOptionsWithValuesForProperty when display and storage need to differ.
*
* @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")
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;
};

View File

@@ -2400,11 +2400,26 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
// Value widget depends on property type
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;
Row.CustomOptionDisplayToValue.Empty();
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)
@@ -2648,17 +2663,52 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
FString CurrentVal;
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
if (Row.EnumOptions.IsValid())
{
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
{
if (*Opt == CurrentVal)
if (*Opt == DisplayToShow)
{
Row.EnumCombo->SetSelectedItem(Opt);
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
break;
}
}
@@ -2804,8 +2854,25 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
{
// Custom options combo: use selected text directly as property value
NewValueText = Row.EnumComboText->GetText().ToString();
// Custom options combo. If we have a display->value map (structured variant),
// 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())
{

View File

@@ -31,6 +31,13 @@ struct FPS_Editor_PropertyRowWidgets
TSharedPtr<STextBlock> EnumComboText;
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
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;
};
/**

View File

@@ -2063,11 +2063,26 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
// Value widget depends on property type
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;
Row.CustomOptionDisplayToValue.Empty();
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)
@@ -2307,17 +2322,52 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
FString CurrentVal;
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
if (Row.EnumOptions.IsValid())
{
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
{
if (*Opt == CurrentVal)
if (*Opt == DisplayToShow)
{
Row.EnumCombo->SetSelectedItem(Opt);
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
break;
}
}
@@ -2463,8 +2513,25 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
{
// Custom options combo: use selected text directly as property value
NewValueText = Row.EnumComboText->GetText().ToString();
// Custom options combo. If we have a display->value map (structured variant),
// 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())
{

View File

@@ -29,6 +29,9 @@ struct FPS_Editor_PropertyRowWidgetsLegacy
TSharedPtr<STextBlock> EnumComboText;
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
UEnum* CachedEnum = nullptr;
/** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */
TMap<FString, FString> CustomOptionDisplayToValue;
};
/**