From 6736190553465d40cd920e595e24334a82f94b92 Mon Sep 17 00:00:00 2001 From: Adnan Mujkic Date: Sat, 5 Sep 2026 21:20:30 +0200 Subject: [PATCH] Item editor implementation --- .../Private/ItemEditorWindow.cpp | 896 ++++++++++++++++++ .../Private/ProjectEleriEditorModule.cpp | 112 ++- .../ProjectEleriEditor.Build.cs | 3 + .../Public/ItemEditorWindow.h | 116 +++ .../Public/ProjectEleriEditorModule.h | 20 +- 5 files changed, 1126 insertions(+), 21 deletions(-) create mode 100644 Source/ProjectEleriEditor/Private/ItemEditorWindow.cpp create mode 100644 Source/ProjectEleriEditor/Public/ItemEditorWindow.h diff --git a/Source/ProjectEleriEditor/Private/ItemEditorWindow.cpp b/Source/ProjectEleriEditor/Private/ItemEditorWindow.cpp new file mode 100644 index 00000000..95f60700 --- /dev/null +++ b/Source/ProjectEleriEditor/Private/ItemEditorWindow.cpp @@ -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(); + if (!Enum) + { + return INDEX_NONE; + } + + FString TagValue; + if (!InAsset.GetTagValue(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(); + if (!Enum || InCategoryValue < 0) + { + return FString(); + } + return Enum->GetNameStringByValue((int64)InCategoryValue); +} +} // namespace + +FString SItemEditorWindow::GetTaggedItemName(const FAssetData& InAsset) +{ + FString ItemName; + if (InAsset.GetTagValue(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(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) + .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(); + 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 SItemEditorWindow::MakeCategoryFilterWidget() +{ + const UEnum* Enum = StaticEnum(); + + TSharedRef 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 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(TEXT("AssetRegistry")).Get(); + + TArray Assets; + AssetRegistry.GetAssetsByClass(UItemDataAsset::StaticClass()->GetClassPathName(), Assets, /*bSearchSubClasses=*/true); + + ItemEntries.Reset(); + ItemEntries.Reserve(Assets.Num()); + for (FAssetData& Asset : Assets) + { + TSharedPtr Entry = MakeShared(); + Entry->AssetData = Asset; + + // Prefer the live object so name/category are always exact; registry tags are the fallback. + UItemDataAsset* DataAsset = Cast(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 SItemEditorWindow::OnGenerateRow(FItemEntry InItem, const TSharedRef& 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, 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(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{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()) + { + AssetEditorSubsystem->OpenEditorForAsset(InAsset); + } +} + +void SItemEditorWindow::ShowInContentBrowser(UItemDataAsset* InAsset) +{ + if (!InAsset) + { + return; + } + + FContentBrowserModule& ContentBrowserModule = FModuleManager::LoadModuleChecked(TEXT("ContentBrowser")); + ContentBrowserModule.Get().SyncBrowserToAssets(TArray{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 + diff --git a/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp b/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp index b9b250f6..f26ef533 100644 --- a/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp +++ b/Source/ProjectEleriEditor/Private/ProjectEleriEditorModule.cpp @@ -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("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("PropertyEditor"); - PropEd.UnregisterCustomClassLayout(TEXT("ItemDataAsset")); - } - } \ No newline at end of file +const FName FProjectEleriEditorModule::ItemEditorTabName(TEXT("ProjectEleriItemEditor")); + +void FProjectEleriEditorModule::StartupModule() +{ + // Details customization for the item data asset (property map generation widget). + FPropertyEditorModule& PropEd = FModuleManager::LoadModuleChecked("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("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 diff --git a/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs b/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs index 8609581e..6e6e86d4 100644 --- a/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs +++ b/Source/ProjectEleriEditor/ProjectEleriEditor.Build.cs @@ -19,6 +19,9 @@ public class ProjectEleriEditor : ModuleRules "InputCore", "EditorSubsystem", "Projects", + "ToolMenus", + "AssetRegistry", + "ContentBrowser", "ProjectEleri"}); } } \ No newline at end of file diff --git a/Source/ProjectEleriEditor/Public/ItemEditorWindow.h b/Source/ProjectEleriEditor/Public/ItemEditorWindow.h new file mode 100644 index 00000000..ec1d3753 --- /dev/null +++ b/Source/ProjectEleriEditor/Public/ItemEditorWindow.h @@ -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; + + // Slate callbacks + TSharedRef OnGenerateRow(FItemEntry InItem, const TSharedRef& 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 MakeCategoryFilterWidget(); + TSharedRef 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> ItemEntries; + + /** Subset of ItemEntries currently visible after applying the search filter. */ + TArray FilteredItems; + + /** Ordered category values offered as filter chips (underlying Category enum values). */ + TArray CategoryFilterValues; + + /** ItemCategory values that should be shown in the list; empty means show every category. */ + TSet SelectedCategoryValues; + + /** The data asset currently being edited. */ + TWeakObjectPtr CurrentAsset; + + TSharedPtr SearchBox; + TSharedPtr> ListView; + TSharedPtr DetailsView; + TSharedPtr StatusText; + + FText SearchFilterText; + bool bAutoSaveOnChange = false; +}; diff --git a/Source/ProjectEleriEditor/Public/ProjectEleriEditorModule.h b/Source/ProjectEleriEditor/Public/ProjectEleriEditorModule.h index e166b01b..048b3342 100644 --- a/Source/ProjectEleriEditor/Public/ProjectEleriEditorModule.h +++ b/Source/ProjectEleriEditor/Public/ProjectEleriEditorModule.h @@ -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; -}; \ No newline at end of file +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; +};