Item editor implementation

This commit is contained in:
2026-09-05 21:20:30 +02:00
parent 42b8944a62
commit 6736190553
5 changed files with 1126 additions and 21 deletions

View File

@@ -0,0 +1,896 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ItemEditorWindow.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "ContentBrowserModule.h"
#include "IContentBrowserSingleton.h"
#include "DetailsViewArgs.h"
#include "Editor.h"
#include "FileHelpers.h"
#include "IDetailsView.h"
#include "Modules/ModuleManager.h"
#include "PropertyEditorModule.h"
#include "Styling/CoreStyle.h"
#include "Styling/SlateColor.h"
#include "Subsystems/AssetEditorSubsystem.h"
#include "UObject/Class.h"
#include "UObject/UnrealType.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SSearchBox.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SSplitter.h"
#include "Widgets/Layout/SWrapBox.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Views/STableRow.h"
#include "ProjectEleri/Items/DataAssets/ItemDataAsset.h"
#define LOCTEXT_NAMESPACE "ItemEditorWindow"
/*-----------------------------------------------------------------------------
Static helpers
-----------------------------------------------------------------------------*/
namespace
{
/** Best-effort conversion of the ItemCategory registry tag into an underlying Category enum value. */
int32 ParseCategoryFromRegistry(const FAssetData& InAsset)
{
const UEnum* Enum = StaticEnum<Category>();
if (!Enum)
{
return INDEX_NONE;
}
FString TagValue;
if (!InAsset.GetTagValue<FString>(TEXT("ItemCategory"), TagValue))
{
return INDEX_NONE;
}
TagValue.TrimStartAndEndInline();
if (TagValue.IsEmpty())
{
return INDEX_NONE;
}
// Some exports store the underlying integer ("2"), most store the value name ("Plant").
if (TagValue.IsNumeric())
{
const int64 NumericValue = FCString::Atoi64(*TagValue);
return Enum->GetIndexByValue(NumericValue) != INDEX_NONE ? (int32)NumericValue : INDEX_NONE;
}
int64 ParsedValue = Enum->GetValueByNameString(TagValue);
if (ParsedValue != INDEX_NONE)
{
return (int32)ParsedValue;
}
// Defensive: strip a possible "Category::Plant" style prefix.
const int32 ScopeIndex = TagValue.Find(TEXT("::"));
if (ScopeIndex != INDEX_NONE)
{
ParsedValue = Enum->GetValueByNameString(TagValue.RightChop(ScopeIndex + 2));
if (ParsedValue != INDEX_NONE)
{
return (int32)ParsedValue;
}
}
return INDEX_NONE;
}
/** Short display name of a Category value ("Plant"), or empty when not resolvable. */
FString GetCategoryShortName(int32 InCategoryValue)
{
const UEnum* Enum = StaticEnum<Category>();
if (!Enum || InCategoryValue < 0)
{
return FString();
}
return Enum->GetNameStringByValue((int64)InCategoryValue);
}
} // namespace
FString SItemEditorWindow::GetTaggedItemName(const FAssetData& InAsset)
{
FString ItemName;
if (InAsset.GetTagValue<FString>(TEXT("ItemName"), ItemName))
{
return ItemName;
}
return FString();
}
FString SItemEditorWindow::GetEntryDisplayName(const FItemListEntry& InEntry)
{
if (!InEntry.ItemName.IsEmpty())
{
return InEntry.ItemName;
}
// No ItemName tag on disk yet: fall back to the asset file name.
return InEntry.AssetData.AssetName.ToString();
}
FString SItemEditorWindow::BuildEntrySearchText(const FItemListEntry& InEntry)
{
FString Combined = FString::Printf(TEXT("%s %s %s %s"),
*GetEntryDisplayName(InEntry),
*GetCategoryShortName(InEntry.CategoryValue),
*InEntry.AssetData.AssetName.ToString(),
*InEntry.AssetData.PackageName.ToString());
return Combined.ToLower();
}
/*-----------------------------------------------------------------------------
Widget construction
-----------------------------------------------------------------------------*/
SItemEditorWindow::~SItemEditorWindow()
{
if (DetailsView.IsValid())
{
DetailsView->OnFinishedChangingProperties().RemoveAll(this);
}
}
void SItemEditorWindow::Construct(const FArguments& InArgs)
{
// --- Details view that edits the selected data asset object directly ---
FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>(TEXT("PropertyEditor"));
FDetailsViewArgs DetailsArgs;
DetailsArgs.bHideSelectionTip = true;
DetailsArgs.bLockable = false;
DetailsArgs.bAllowSearch = true;
DetailsArgs.bShowOptions = true;
DetailsArgs.bShowObjectLabel = false;
DetailsArgs.bShowModifiedPropertiesOption = true;
DetailsArgs.bAllowFavoriteSystem = true;
DetailsArgs.NameAreaSettings = FDetailsViewArgs::HideNameArea;
DetailsView = PropertyEditorModule.CreateDetailView(DetailsArgs);
DetailsView->SetObject(nullptr);
DetailsView->OnFinishedChangingProperties().AddRaw(this, &SItemEditorWindow::OnAssetPropertyChanged);
InitCategoryFilter();
ChildSlot
[
SNew(SVerticalBox)
// ---- Search box + refresh button ------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(4.f, 4.f, 4.f, 6.f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.f)
.VAlign(VAlign_Center)
[
SAssignNew(SearchBox, SSearchBox)
.HintText(LOCTEXT("SearchHint", "Type an item name to find it..."))
.OnTextChanged(this, &SItemEditorWindow::OnSearchTextChanged)
.OnTextCommitted(this, &SItemEditorWindow::OnSearchTextCommitted)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(6.f, 0.f, 0.f, 0.f)
[
SNew(SButton)
.Text(LOCTEXT("RefreshList", "Refresh"))
.ToolTipText(LOCTEXT("RefreshTip", "Re-scan the Asset Registry for every item data asset."))
.OnClicked(this, &SItemEditorWindow::OnRefreshClicked)
]
]
// ---- Category filter chips (multi select, empty selection = all) ----------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(4.f, 0.f, 4.f, 6.f)
[
MakeCategoryFilterWidget()
]
// ---- List of items (left) + details view (right) -------------------------------
+ SVerticalBox::Slot()
.FillHeight(1.f)
.Padding(4.f, 0.f, 4.f, 0.f)
[
SNew(SSplitter)
.Orientation(Orient_Horizontal)
+ SSplitter::Slot()
.Value(0.32f)
.MinSize(240.f)
[
SNew(SBorder)
.BorderImage(FCoreStyle::Get().GetBrush(TEXT("Border")))
.Padding(4.f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.f, 2.f, 0.f, 4.f)
[
SNew(STextBlock)
.Text(LOCTEXT("ItemsLabel", "Items"))
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Bold"), 11))
]
+ SVerticalBox::Slot()
.FillHeight(1.f)
[
SAssignNew(ListView, SListView<FItemEntry>)
.ListItemsSource(&FilteredItems)
.SelectionMode(ESelectionMode::Single)
.OnGenerateRow(this, &SItemEditorWindow::OnGenerateRow)
.OnSelectionChanged(this, &SItemEditorWindow::OnItemSelectionChanged)
.OnMouseButtonDoubleClick(this, &SItemEditorWindow::OnItemDoubleClicked)
]
]
]
+ SSplitter::Slot()
.Value(0.68f)
.MinSize(360.f)
[
SNew(SBorder)
.BorderImage(FCoreStyle::Get().GetBrush(TEXT("Border")))
.Padding(4.f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.f, 2.f, 0.f, 6.f)
[
SNew(STextBlock)
.Text(this, &SItemEditorWindow::GetSelectionStatusText)
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Bold"), 11))
]
+ SVerticalBox::Slot()
.FillHeight(1.f)
[
DetailsView->AsShared()
]
]
]
]
// ---- Status bar with auto-save + save actions ----------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(4.f, 4.f, 4.f, 4.f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(0.f, 0.f, 12.f, 0.f)
[
SNew(SCheckBox)
.ToolTipText(LOCTEXT("AutoSaveTip", "Save the data asset to disk automatically after every change."))
.IsChecked_Lambda([this]() -> ECheckBoxState
{
return bAutoSaveOnChange ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged(this, &SItemEditorWindow::OnAutoSaveCheckChanged)
[
SNew(STextBlock)
.Text(LOCTEXT("AutoSaveLabel", "Auto-save on edit"))
]
]
+ SHorizontalBox::Slot()
.FillWidth(1.f)
.VAlign(VAlign_Center)
.Padding(0.f, 0.f, 12.f, 0.f)
[
SAssignNew(StatusText, STextBlock)
.Text(FText::GetEmpty())
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Regular"), 9))
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.f, 0.f, 0.f, 0.f)
[
SNew(SButton)
.Text(LOCTEXT("SaveAsset", "Save"))
.ToolTipText(LOCTEXT("SaveTip", "Write the selected data asset to disk."))
.IsEnabled_Lambda([this]() { return CurrentAsset.IsValid(); })
.OnClicked(this, &SItemEditorWindow::OnSaveClicked)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.f, 0.f, 0.f, 0.f)
[
SNew(SButton)
.Text(LOCTEXT("OpenEditor", "Open Asset Editor"))
.ToolTipText(LOCTEXT("OpenEditorTip", "Open the selected asset in the regular data asset editor."))
.IsEnabled_Lambda([this]() { return CurrentAsset.IsValid(); })
.OnClicked(this, &SItemEditorWindow::OnOpenAssetEditorClicked)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.f, 0.f, 0.f, 0.f)
[
SNew(SButton)
.Text(LOCTEXT("FindInCB", "Find in Content Browser"))
.ToolTipText(LOCTEXT("FindInCBTip", "Select the asset in the Content Browser."))
.IsEnabled_Lambda([this]() { return CurrentAsset.IsValid(); })
.OnClicked(this, &SItemEditorWindow::OnFindInContentBrowserClicked)
]
]
];
RefreshAssetList();
}
/*-----------------------------------------------------------------------------
Category filter
-----------------------------------------------------------------------------*/
void SItemEditorWindow::InitCategoryFilter()
{
CategoryFilterValues.Reset();
SelectedCategoryValues.Reset();
const UEnum* Enum = StaticEnum<Category>();
if (!Enum)
{
return;
}
for (int32 Index = 0; Index < Enum->NumEnums(); ++Index)
{
if (Enum->HasMetaData(TEXT("Hidden"), Index))
{
continue; // e.g. Category::MAX
}
const int64 Value = Enum->GetValueByIndex(Index);
if (Value >= 0)
{
CategoryFilterValues.Add((int32)Value);
}
}
}
TSharedRef<SWidget> SItemEditorWindow::MakeCategoryFilterWidget()
{
const UEnum* Enum = StaticEnum<Category>();
TSharedRef<SWrapBox> WrapBox = SNew(SWrapBox)
.UseAllottedSize(true)
.InnerSlotPadding(FVector2D(4.f, 3.f));
// "All" chip - clears every category selection.
WrapBox->AddSlot()
[
MakeCategoryCheckBox(INDEX_NONE, LOCTEXT("AllCategories", "All"))
];
for (const int32 CategoryValue : CategoryFilterValues)
{
FText Label;
if (Enum)
{
Label = Enum->GetDisplayNameTextByValue((int64)CategoryValue);
}
if (Label.IsEmpty())
{
Label = FText::FromString(FString::FromInt(CategoryValue));
}
WrapBox->AddSlot()
[
MakeCategoryCheckBox(CategoryValue, Label)
];
}
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(FMargin(0.f, 0.f, 10.f, 0.f))
[
SNew(STextBlock)
.Text(LOCTEXT("CategoryFilterLabel", "Category:"))
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Bold"), 10))
]
+ SHorizontalBox::Slot()
.FillWidth(1.f)
.VAlign(VAlign_Center)
[
WrapBox
];
}
TSharedRef<SWidget> SItemEditorWindow::MakeCategoryCheckBox(int32 InCategoryValue, const FText& InLabel)
{
return SNew(SCheckBox)
.Padding(FMargin(2.f, 1.f, 4.f, 1.f))
.IsChecked_Lambda([this, InCategoryValue]()
{
return IsCategoryChecked(InCategoryValue);
})
.OnCheckStateChanged_Lambda([this, InCategoryValue](ECheckBoxState InState)
{
OnCategoryToggled(InCategoryValue, InState);
})
[
SNew(STextBlock)
.Text(InLabel)
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Regular"), 10))
];
}
ECheckBoxState SItemEditorWindow::IsCategoryChecked(int32 InCategoryValue) const
{
if (InCategoryValue == INDEX_NONE)
{
// "All" is active when no specific category is selected.
return SelectedCategoryValues.Num() == 0 ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
return SelectedCategoryValues.Contains(InCategoryValue) ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}
void SItemEditorWindow::OnCategoryToggled(int32 InCategoryValue, ECheckBoxState InState)
{
if (InCategoryValue == INDEX_NONE)
{
SelectedCategoryValues.Reset();
}
else if (InState == ECheckBoxState::Checked)
{
SelectedCategoryValues.Add(InCategoryValue);
}
else
{
SelectedCategoryValues.Remove(InCategoryValue);
}
ApplyFilter();
SetStatusMessage(FString::Printf(TEXT("%d of %d items shown"), FilteredItems.Num(), ItemEntries.Num()));
}
/*-----------------------------------------------------------------------------
Item list / filtering
-----------------------------------------------------------------------------*/
void SItemEditorWindow::RefreshAssetList()
{
const UItemDataAsset* PreviouslySelected = GetCurrentAsset();
IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
TArray<FAssetData> Assets;
AssetRegistry.GetAssetsByClass(UItemDataAsset::StaticClass()->GetClassPathName(), Assets, /*bSearchSubClasses=*/true);
ItemEntries.Reset();
ItemEntries.Reserve(Assets.Num());
for (FAssetData& Asset : Assets)
{
TSharedPtr<FItemListEntry> Entry = MakeShared<FItemListEntry>();
Entry->AssetData = Asset;
// Prefer the live object so name/category are always exact; registry tags are the fallback.
UItemDataAsset* DataAsset = Cast<UItemDataAsset>(Asset.GetAsset());
if (DataAsset)
{
Entry->ItemName = DataAsset->ItemName.ToString();
Entry->CategoryValue = (int32)DataAsset->ItemCategory;
}
else
{
Entry->ItemName = GetTaggedItemName(Asset);
Entry->CategoryValue = ParseCategoryFromRegistry(Asset);
}
Entry->SearchText = BuildEntrySearchText(*Entry);
ItemEntries.Add(Entry);
}
ApplyFilter();
// Restore the list highlight for the asset we were editing (details view stays untouched).
if (PreviouslySelected && ListView)
{
if (FItemEntry Entry = FindEntryForAsset(PreviouslySelected))
{
ListView->SetSelection(Entry, ESelectInfo::Direct);
}
}
SetStatusMessage(FString::Printf(TEXT("%d item data assets found (%d shown)"), ItemEntries.Num(), FilteredItems.Num()));
}
void SItemEditorWindow::ApplyFilter()
{
FString SearchTerm = SearchFilterText.ToString();
SearchTerm.TrimStartAndEndInline();
const FString SearchLower = SearchTerm.ToLower();
FilteredItems.Reset();
const bool bFilteringByCategory = SelectedCategoryValues.Num() > 0;
for (const FItemEntry& Entry : ItemEntries)
{
// Name / asset search term.
if (!SearchLower.IsEmpty() && !Entry->SearchText.Contains(SearchLower))
{
continue;
}
// Category inclusion filter (empty selection shows every category).
if (bFilteringByCategory &&
(Entry->CategoryValue == INDEX_NONE || !SelectedCategoryValues.Contains(Entry->CategoryValue)))
{
continue;
}
FilteredItems.Add(Entry);
}
// Case insensitive alphabetical sort by item name.
FilteredItems.Sort([](const FItemEntry& A, const FItemEntry& B)
{
const FString NameA = A->ItemName.ToLower();
const FString NameB = B->ItemName.ToLower();
if (NameA == NameB)
{
return A->AssetData.AssetName.LexicalLess(B->AssetData.AssetName);
}
return NameA < NameB;
});
if (ListView)
{
ListView->RequestListRefresh();
}
}
void SItemEditorWindow::SelectFirstVisibleItem()
{
if (FilteredItems.Num() == 0)
{
return;
}
FItemEntry FirstItem = FilteredItems[0];
ListView->SetSelection(FirstItem, ESelectInfo::OnNavigation);
ListView->RequestScrollIntoView(FirstItem);
}
SItemEditorWindow::FItemEntry SItemEditorWindow::FindEntryForAsset(const UItemDataAsset* InAsset)
{
if (!InAsset)
{
return nullptr;
}
const FString AssetPath = InAsset->GetPathName();
for (const FItemEntry& Entry : ItemEntries)
{
if (Entry->AssetData.GetObjectPathString() == AssetPath)
{
return Entry;
}
}
return nullptr;
}
void SItemEditorWindow::UpdateEntryFromLoadedAsset(FItemEntry InEntry, const UItemDataAsset* InAsset)
{
if (!InEntry || !InAsset)
{
return;
}
InEntry->ItemName = InAsset->ItemName.ToString();
InEntry->CategoryValue = (int32)InAsset->ItemCategory;
InEntry->SearchText = BuildEntrySearchText(*InEntry);
}
/*-----------------------------------------------------------------------------
Item rows
-----------------------------------------------------------------------------*/
TSharedRef<ITableRow> SItemEditorWindow::OnGenerateRow(FItemEntry InItem, const TSharedRef<STableViewBase>& InOwnerTable)
{
check(InItem.IsValid());
const FString CategoryLabel = GetCategoryShortName(InItem->CategoryValue);
const FString AssetPath = CategoryLabel.IsEmpty()
? FString::Printf(TEXT("%s / %s"),
*InItem->AssetData.PackageName.ToString(),
*InItem->AssetData.AssetName.ToString())
: FString::Printf(TEXT("%s / %s (%s)"),
*InItem->AssetData.PackageName.ToString(),
*InItem->AssetData.AssetName.ToString(),
*CategoryLabel);
return SNew(STableRow<FItemEntry>, InOwnerTable)
.Padding(FMargin(4.f, 2.f, 4.f, 2.f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(GetEntryDisplayName(*InItem)))
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Bold"), 10))
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(AssetPath))
.Font(FCoreStyle::GetDefaultFontStyle(TEXT("Regular"), 8))
.ColorAndOpacity(FSlateColor(FLinearColor(0.55f, 0.55f, 0.55f)))
]
];
}
/*-----------------------------------------------------------------------------
Search & selection callbacks
-----------------------------------------------------------------------------*/
void SItemEditorWindow::OnSearchTextChanged(const FText& InText)
{
SearchFilterText = InText;
ApplyFilter();
if (FilteredItems.Num() == 0)
{
SetStatusMessage(TEXT("No items match that name."));
}
else
{
SetStatusMessage(FString::Printf(TEXT("%d of %d items shown"), FilteredItems.Num(), ItemEntries.Num()));
}
}
void SItemEditorWindow::OnSearchTextCommitted(const FText& InText, ETextCommit::Type InCommitType)
{
if (InCommitType == ETextCommit::OnEnter)
{
SelectFirstVisibleItem();
}
}
void SItemEditorWindow::OnItemSelectionChanged(FItemEntry InItem, ESelectInfo::Type InSelectInfo)
{
// Programmatic selections (e.g. restoring the highlight after a refresh) are already handled.
if (InSelectInfo == ESelectInfo::Direct)
{
return;
}
if (!InItem)
{
return;
}
UItemDataAsset* Asset = LoadAssetForEntry(InItem);
if (!Asset)
{
return;
}
CurrentAsset = Asset;
DetailsView->SetObject(Asset, /*bForceRefresh=*/true);
SetStatusMessage(FString::Printf(TEXT("Editing %s"), *Asset->GetPathName()));
}
void SItemEditorWindow::OnItemDoubleClicked(FItemEntry InItem)
{
if (!InItem)
{
return;
}
UItemDataAsset* Asset = LoadAssetForEntry(InItem);
if (!Asset)
{
return;
}
CurrentAsset = Asset;
DetailsView->SetObject(Asset, /*bForceRefresh=*/true);
OpenInAssetEditor(Asset);
}
void SItemEditorWindow::OnAssetPropertyChanged(const FPropertyChangedEvent& InEvent)
{
if (UItemDataAsset* Asset = GetCurrentAsset())
{
// Keep the list label in sync (e.g. when ItemName is edited).
if (FItemEntry Entry = FindEntryForAsset(Asset))
{
UpdateEntryFromLoadedAsset(Entry, Asset);
if (ListView)
{
ListView->RequestListRefresh();
}
}
if (bAutoSaveOnChange)
{
SaveCurrentAsset();
}
}
}
/*-----------------------------------------------------------------------------
Asset loading / accessors
-----------------------------------------------------------------------------*/
UItemDataAsset* SItemEditorWindow::GetCurrentAsset() const
{
return CurrentAsset.Get();
}
UItemDataAsset* SItemEditorWindow::LoadAssetForEntry(FItemEntry InItem)
{
if (!InItem)
{
return nullptr;
}
UObject* LoadedObject = InItem->AssetData.GetAsset();
if (!LoadedObject)
{
SetStatusMessage(FString::Printf(TEXT("Failed to load %s."), *InItem->AssetData.GetObjectPathString()), /*bIsError=*/true);
return nullptr;
}
UItemDataAsset* Asset = Cast<UItemDataAsset>(LoadedObject);
if (!Asset)
{
SetStatusMessage(FString::Printf(TEXT("%s is not an ItemDataAsset."), *InItem->AssetData.GetObjectPathString()), /*bIsError=*/true);
return nullptr;
}
return Asset;
}
/*-----------------------------------------------------------------------------
Actions
-----------------------------------------------------------------------------*/
FReply SItemEditorWindow::OnRefreshClicked()
{
RefreshAssetList();
return FReply::Handled();
}
FReply SItemEditorWindow::OnSaveClicked()
{
SaveCurrentAsset();
return FReply::Handled();
}
FReply SItemEditorWindow::OnOpenAssetEditorClicked()
{
if (UItemDataAsset* Asset = GetCurrentAsset())
{
OpenInAssetEditor(Asset);
}
return FReply::Handled();
}
FReply SItemEditorWindow::OnFindInContentBrowserClicked()
{
if (UItemDataAsset* Asset = GetCurrentAsset())
{
ShowInContentBrowser(Asset);
}
return FReply::Handled();
}
void SItemEditorWindow::OnAutoSaveCheckChanged(ECheckBoxState InState)
{
bAutoSaveOnChange = (InState == ECheckBoxState::Checked);
SetStatusMessage(bAutoSaveOnChange ? TEXT("Auto-save enabled") : TEXT("Auto-save disabled"));
}
void SItemEditorWindow::SaveCurrentAsset()
{
UItemDataAsset* Asset = GetCurrentAsset();
if (!Asset)
{
SetStatusMessage(TEXT("No item selected."), /*bIsError=*/true);
return;
}
UPackage* Package = Asset->GetPackage();
if (!Package)
{
SetStatusMessage(TEXT("Selected item has no package."), /*bIsError=*/true);
return;
}
Package->SetDirtyFlag(true);
if (UEditorLoadingAndSavingUtils::SavePackages(TArray<UPackage*>{Package}, /*bOnlyDirty=*/true))
{
if (FItemEntry Entry = FindEntryForAsset(Asset))
{
UpdateEntryFromLoadedAsset(Entry, Asset);
if (ListView)
{
ListView->RequestListRefresh();
}
}
SetStatusMessage(FString::Printf(TEXT("Saved %s"), *Asset->GetName()));
}
else
{
SetStatusMessage(FString::Printf(TEXT("Failed to save %s."), *Asset->GetName()), /*bIsError=*/true);
}
}
void SItemEditorWindow::OpenInAssetEditor(UItemDataAsset* InAsset)
{
if (!InAsset)
{
return;
}
if (UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>())
{
AssetEditorSubsystem->OpenEditorForAsset(InAsset);
}
}
void SItemEditorWindow::ShowInContentBrowser(UItemDataAsset* InAsset)
{
if (!InAsset)
{
return;
}
FContentBrowserModule& ContentBrowserModule = FModuleManager::LoadModuleChecked<FContentBrowserModule>(TEXT("ContentBrowser"));
ContentBrowserModule.Get().SyncBrowserToAssets(TArray<UObject*>{InAsset});
}
void SItemEditorWindow::SetStatusMessage(const FString& InMessage, bool bIsError)
{
if (!StatusText)
{
return;
}
const FLinearColor TextColor = bIsError ? FLinearColor(1.f, 0.35f, 0.35f) : FLinearColor(0.7f, 0.7f, 0.7f);
StatusText->SetColorAndOpacity(FSlateColor(TextColor));
StatusText->SetText(FText::FromString(InMessage));
}
FText SItemEditorWindow::GetSelectionStatusText() const
{
if (UItemDataAsset* Asset = GetCurrentAsset())
{
return FText::FromString(Asset->GetPathName());
}
return LOCTEXT("NoSelection", "Select an item to edit it");
}
#undef LOCTEXT_NAMESPACE

View File

@@ -1,23 +1,103 @@
#include "ProjectEleriEditorModule.h"
#include "Framework/Docking/TabManager.h"
#include "Modules/ModuleManager.h"
#include "PropertyEditorModule.h"
#include "ToolMenus.h"
#include "Widgets/Docking/SDockTab.h"
#include "ItemDataAssetCustomization.h"
#include "ItemEditorWindow.h"
IMPLEMENT_GAME_MODULE(FProjectEleriEditorModule, ProjectEleriEditor);
#define LOCTEXT_NAMESPACE "ProjectEleriEditorModule"
void FProjectEleriEditorModule::StartupModule()
{
FPropertyEditorModule& PropEd = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.RegisterCustomClassLayout(
TEXT("ItemDataAsset"), // UCLASS name
FOnGetDetailCustomizationInstance::CreateStatic(&FItemDataAssetCustomization::MakeInstance)
);
}
IMPLEMENT_GAME_MODULE(FProjectEleriEditorModule, ProjectEleriEditor);
void FProjectEleriEditorModule::ShutdownModule()
{
if (FModuleManager::Get().IsModuleLoaded("PropertyEditor")) {
FPropertyEditorModule& PropEd = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset"));
}
}
const FName FProjectEleriEditorModule::ItemEditorTabName(TEXT("ProjectEleriItemEditor"));
void FProjectEleriEditorModule::StartupModule()
{
// Details customization for the item data asset (property map generation widget).
FPropertyEditorModule& PropEd = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.RegisterCustomClassLayout(
TEXT("ItemDataAsset"),
FOnGetDetailCustomizationInstance::CreateStatic(&FItemDataAssetCustomization::MakeInstance)
);
// Register the Item Editor as a docking tab.
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(ItemEditorTabName);
}
FGlobalTabmanager::Get()->RegisterNomadTabSpawner(
ItemEditorTabName,
FOnSpawnTab::CreateLambda([](const FSpawnTabArgs& Args)
{
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
.Label(LOCTEXT("ItemEditorTabLabel", "Item Editor"))
.ToolTipText(LOCTEXT("ItemEditorTabTooltip", "Search items by name and edit their data assets"))
[
SNew(SItemEditorWindow)
];
}))
.SetDisplayName(LOCTEXT("ItemEditorDisplayName", "Item Editor"))
.SetMenuType(ETabSpawnerMenuType::Hidden);
// Add a menu entry to open the window. Menus might not be ready yet during startup.
if (UToolMenus::IsToolMenuUIEnabled())
{
RegisterMenus();
}
else
{
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FProjectEleriEditorModule::RegisterMenus));
}
}
void FProjectEleriEditorModule::ShutdownModule()
{
if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
{
FPropertyEditorModule& PropEd = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset"));
}
if (FGlobalTabmanager::Get()->HasTabSpawner(ItemEditorTabName))
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(ItemEditorTabName);
}
UToolMenus::UnregisterOwner(this);
}
void FProjectEleriEditorModule::OpenItemEditorTab()
{
FGlobalTabmanager::Get()->TryInvokeTab(ItemEditorTabName);
}
void FProjectEleriEditorModule::RegisterMenus()
{
FToolMenuOwnerScoped OwnerScoped(this);
if (UToolMenu* WindowMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Window"))
{
FToolMenuSection& Section = WindowMenu->AddSection(
TEXT("ItemEditor"),
LOCTEXT("ItemEditorSectionLabel", "Eleri Tools"),
FToolMenuInsert(NAME_None, EToolMenuInsertType::First)
);
Section.AddMenuEntry(
TEXT("OpenItemEditor"),
LOCTEXT("ItemEditorMenuLabel", "Item Editor"),
LOCTEXT("ItemEditorMenuTooltip", "Open a window to search items by name and edit their data assets."),
FSlateIcon(),
FUIAction(FExecuteAction::CreateRaw(this, &FProjectEleriEditorModule::OpenItemEditorTab))
);
}
}
#undef LOCTEXT_NAMESPACE

View File

@@ -19,6 +19,9 @@ public class ProjectEleriEditor : ModuleRules
"InputCore",
"EditorSubsystem",
"Projects",
"ToolMenus",
"AssetRegistry",
"ContentBrowser",
"ProjectEleri"});
}
}

View File

@@ -0,0 +1,116 @@
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "AssetRegistry/AssetData.h"
#include "Types/SlateEnums.h"
#include "Widgets/SCompoundWidget.h"
#include "Widgets/Views/SListView.h"
class IDetailsView;
class ITableRow;
class SSearchBox;
class STableViewBase;
class STextBlock;
class UItemDataAsset;
struct FPropertyChangedEvent;
/** One row shown in the item list of the editor window. */
struct FItemListEntry
{
/** Registry data of the data asset (never loads the asset on its own). */
FAssetData AssetData;
/** Human readable item name (FText source), used as the row label and for sorting. */
FString ItemName;
/** Underlying value of the ItemCategory enum (e.g. Category::Plant), INDEX_NONE if unknown. */
int32 CategoryValue = INDEX_NONE;
/** Lower-cased text used for filtering (name + category + asset + package path). */
FString SearchText;
};
/**
* Editor tool window that lets the user type an item name, pick the matching
* ItemDataAsset and edit its properties directly on the asset.
*/
class SItemEditorWindow : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SItemEditorWindow) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
virtual ~SItemEditorWindow();
/** Re-queries the Asset Registry for every UItemDataAsset in the project. */
void RefreshAssetList();
private:
using FItemEntry = TSharedPtr<FItemListEntry>;
// Slate callbacks
TSharedRef<ITableRow> OnGenerateRow(FItemEntry InItem, const TSharedRef<STableViewBase>& InOwnerTable);
void OnSearchTextChanged(const FText& InText);
void OnSearchTextCommitted(const FText& InText, ETextCommit::Type InCommitType);
void OnItemSelectionChanged(FItemEntry InItem, ESelectInfo::Type InSelectInfo);
void OnItemDoubleClicked(FItemEntry InItem);
void OnAssetPropertyChanged(const FPropertyChangedEvent& InEvent);
// Toolbar / button handlers
FReply OnRefreshClicked();
FReply OnSaveClicked();
FReply OnOpenAssetEditorClicked();
FReply OnFindInContentBrowserClicked();
void OnAutoSaveCheckChanged(ECheckBoxState InState);
// Category filter
void InitCategoryFilter();
TSharedRef<SWidget> MakeCategoryFilterWidget();
TSharedRef<SWidget> MakeCategoryCheckBox(int32 InCategoryValue, const FText& InLabel);
ECheckBoxState IsCategoryChecked(int32 InCategoryValue) const;
void OnCategoryToggled(int32 InCategoryValue, ECheckBoxState InState);
// Helpers
void ApplyFilter();
void SelectFirstVisibleItem();
FItemEntry FindEntryForAsset(const UItemDataAsset* InAsset);
UItemDataAsset* LoadAssetForEntry(FItemEntry InItem);
UItemDataAsset* GetCurrentAsset() const;
void UpdateEntryFromLoadedAsset(FItemEntry InEntry, const UItemDataAsset* InAsset);
void SaveCurrentAsset();
void SetStatusMessage(const FString& InMessage, bool bIsError = false);
void OpenInAssetEditor(UItemDataAsset* InAsset);
void ShowInContentBrowser(UItemDataAsset* InAsset);
FText GetSelectionStatusText() const;
static FString GetEntryDisplayName(const FItemListEntry& InEntry);
static FString BuildEntrySearchText(const FItemListEntry& InEntry);
static FString GetTaggedItemName(const FAssetData& InAsset);
/** All data assets of type UItemDataAsset found in the project (registry only). */
TArray<TSharedPtr<FItemListEntry>> ItemEntries;
/** Subset of ItemEntries currently visible after applying the search filter. */
TArray<FItemEntry> FilteredItems;
/** Ordered category values offered as filter chips (underlying Category enum values). */
TArray<int32> CategoryFilterValues;
/** ItemCategory values that should be shown in the list; empty means show every category. */
TSet<int32> SelectedCategoryValues;
/** The data asset currently being edited. */
TWeakObjectPtr<UItemDataAsset> CurrentAsset;
TSharedPtr<SSearchBox> SearchBox;
TSharedPtr<SListView<FItemEntry>> ListView;
TSharedPtr<IDetailsView> DetailsView;
TSharedPtr<STextBlock> StatusText;
FText SearchFilterText;
bool bAutoSaveOnChange = false;
};

View File

@@ -1,10 +1,20 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleInterface.h"
#include "Modules/ModuleManager.h"
class FProjectEleriEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
/** Opens (or focuses) the Item Editor window. */
void OpenItemEditorTab();
private:
void RegisterMenus();
/** Tab id of the Item Editor window. */
static const FName ItemEditorTabName;
};