// Copyright Epic Games, Inc. All Rights Reserved. #include "SComponentClassCombo.h" #include "Widgets/Layout/SSpacer.h" #include "Widgets/Images/SImage.h" #include "Widgets/SToolTip.h" #include "Widgets/Views/SListView.h" #include "Widgets/Input/SComboBox.h" #include "Styling/AppStyle.h" #include "Components/SceneComponent.h" #include "Engine/Blueprint.h" #include "Engine/Selection.h" #include "Editor.h" #include "Styling/SlateIconFinder.h" #include "ComponentAssetBroker.h" #include "ComponentTypeRegistry.h" #include "EditorClassUtils.h" #include "Widgets/Input/SSearchBox.h" #include "SListViewSelectorDropdownMenu.h" #include "Misc/TextFilterExpressionEvaluator.h" #include "SPositiveActionButton.h" #include "Widgets/Layout/SSeparator.h" #include "Modules/ModuleManager.h" #include "ClassViewerModule.h" #include "ClassViewerFilter.h" #include "Framework/MultiBox/MultiBoxBuilder.h" #define LOCTEXT_NAMESPACE "ComponentClassCombo" FString FComponentClassComboEntry::GetClassDisplayName() const { return ComponentClass != nullptr ? ComponentClass->GetDisplayNameText().ToString() : ComponentName; } FString FComponentClassComboEntry::GetClassName() const { return ComponentClass != nullptr ? ComponentClass->GetName() : ComponentName; } void FComponentClassComboEntry::AddReferencedObjects(FReferenceCollector& Collector) { Collector.AddReferencedObject(ComponentClass.GetGCPtr()); UClass* RawClass = ComponentClass; if(RawClass && RawClass->IsChildOf(UActorComponent::StaticClass())) { ComponentClass = RawClass; } else { ComponentClass = nullptr; } Collector.AddReferencedObject(IconClass); } bool FComponentClassComboEntry::OnBlueprintGeneratedClassUnloaded(UBlueprintGeneratedClass* BlueprintGeneratedClass) { bool bModified = false; if (BlueprintGeneratedClass == ComponentClass) { ComponentClass = nullptr; bModified = true; } if (BlueprintGeneratedClass == IconClass) { IconClass = nullptr; bModified = true; } return bModified; } void SComponentClassCombo::Construct(const FArguments& InArgs) { PrevSelectedIndex = INDEX_NONE; OnComponentClassSelected = InArgs._OnComponentClassSelected; OnSubobjectClassSelected = InArgs._OnSubobjectClassSelected; TextFilter = MakeShared(ETextFilterExpressionEvaluatorMode::BasicString); ComponentClassFilterData.InitOptions = MakeShared(); ComponentClassFilterData.InitOptions->ClassFilters.Append(InArgs._CustomClassFilters); FClassViewerModule& ClassViewerModule = FModuleManager::LoadModuleChecked("ClassViewer"); ComponentClassFilterData.ClassFilter = ClassViewerModule.CreateClassFilter(*ComponentClassFilterData.InitOptions); ComponentClassFilterData.FilterFuncs = ClassViewerModule.CreateFilterFuncs(); ClassViewerModule.GetOnGlobalClassViewerFilterModified().AddRaw(this, &SComponentClassCombo::UpdateComponentClassList); FComponentTypeRegistry::Get().SubscribeToComponentList(ComponentClassList).AddRaw(this, &SComponentClassCombo::UpdateComponentClassList); UpdateComponentClassList(); SAssignNew(ComponentClassListView, SListView) .ListItemsSource(&FilteredComponentClassList) .OnSelectionChanged( this, &SComponentClassCombo::OnAddComponentSelectionChanged ) .OnGenerateRow( this, &SComponentClassCombo::GenerateAddComponentRow ) .SelectionMode(ESelectionMode::Single); SAssignNew(SearchBox, SSearchBox) .HintText( LOCTEXT( "BlueprintAddComponentSearchBoxHint", "Search Components" ) ) .OnTextChanged( this, &SComponentClassCombo::OnSearchBoxTextChanged ) .OnTextCommitted( this, &SComponentClassCombo::OnSearchBoxTextCommitted ); ChildSlot [ SAssignNew(AddNewButton, SPositiveActionButton) .Icon(FAppStyle::Get().GetBrush("Icons.Plus")) .Text(LOCTEXT("Add", "Add")) .OnComboBoxOpened(this, &SComponentClassCombo::ClearSelection) .MenuContent() [ SNew(SListViewSelectorDropdownMenu, SearchBox, ComponentClassListView) [ SNew(SBorder) .BorderImage(FAppStyle::GetBrush("Menu.Background")) .Padding(2.0f) [ SNew(SBox) .WidthOverride(250.0f) [ SNew(SVerticalBox) +SVerticalBox::Slot() .Padding(1.f) .AutoHeight() [ SNew(SHorizontalBox) +SHorizontalBox::Slot() [ SearchBox.ToSharedRef() ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2.0f, 2.0f) [ SNew(SComboButton) .ContentPadding(0.0f) .ForegroundColor(FSlateColor::UseForeground()) .ComboButtonStyle(FAppStyle::Get(), "SimpleComboButton") .HasDownArrow(false) .Visibility(this, &SComponentClassCombo::GetFilterOptionsButtonVisibility) .OnGetMenuContent(this, &SComponentClassCombo::GetFilterOptionsMenuContent) .ButtonContent() [ SNew(SImage) .Image(FAppStyle::Get().GetBrush("Icons.Settings")) .ColorAndOpacity(FSlateColor::UseForeground()) ] ] ] +SVerticalBox::Slot() .MaxHeight(400) [ ComponentClassListView.ToSharedRef() ] ] ] ] ] ]; ComponentClassListView->EnableToolTipForceField( true ); // The button can automatically handle setting focus to a specified control when the combo button is opened AddNewButton->SetMenuContentWidgetToFocus( SearchBox ); } SComponentClassCombo::~SComponentClassCombo() { FComponentTypeRegistry::Get().GetOnComponentTypeListChanged().RemoveAll(this); if (FClassViewerModule* ClassViewerModule = FModuleManager::Get().GetModulePtr("ClassViewer")) { ClassViewerModule->GetOnGlobalClassViewerFilterModified().RemoveAll(this); } } void SComponentClassCombo::ClearSelection() { SearchBox->SetText(FText::GetEmpty()); PrevSelectedIndex = INDEX_NONE; // Clear the selection in such a way as to also clear the keyboard selector ComponentClassListView->SetSelection(NULL, ESelectInfo::OnNavigation); // Make sure we scroll to the top if (ComponentClassList->Num() > 0) { ComponentClassListView->RequestScrollIntoView((*ComponentClassList)[0]); } } void SComponentClassCombo::GenerateFilteredComponentList() { FilteredComponentClassList.Reset(); int32 LastHeadingIndex = INDEX_NONE; FComponentClassComboEntryPtr* LastHeadingPtr = nullptr; int32 LastSeparatorIndex = INDEX_NONE; FComponentClassComboEntryPtr* LastSeparatorPtr = nullptr; const bool bHasFilterText = !TextFilter->GetFilterText().IsEmpty(); for (int32 ComponentIndex = 0; ComponentIndex < ComponentClassList->Num(); ComponentIndex++) { FComponentClassComboEntryPtr& CurrentEntry = (*ComponentClassList)[ComponentIndex]; if (CurrentEntry->IsHeading()) { LastHeadingIndex = FilteredComponentClassList.Num(); LastHeadingPtr = &CurrentEntry; } else if (CurrentEntry->IsSeparator()) { LastSeparatorIndex = FilteredComponentClassList.Num(); LastSeparatorPtr = &CurrentEntry; } else if(CurrentEntry->IsClass()) { // Disallow class entries that are not to be seen when searching via text. bool bAllowEntry = !bHasFilterText || CurrentEntry->IsIncludedInFilter(); if(bAllowEntry) { // Disallow class entries that don't match the custom class filter, if set. bAllowEntry = IsComponentClassAllowed(CurrentEntry); if (bAllowEntry && bHasFilterText) { // Finally, disallow class entries that don't match the search box text. const FString ComponentName = CurrentEntry->GetClassName(); bAllowEntry = TextFilter->TestTextFilter(FBasicStringFilterExpressionContext(ComponentName)); if(!bAllowEntry) { const FString FriendlyComponentName = GetSanitizedComponentName(CurrentEntry); bAllowEntry = TextFilter->TestTextFilter(FBasicStringFilterExpressionContext(FriendlyComponentName)); } } } if (bAllowEntry) { // Add the heading first if it hasn't already been added if (LastHeadingPtr && LastHeadingIndex != INDEX_NONE) { FilteredComponentClassList.Insert(*LastHeadingPtr, LastHeadingIndex); LastHeadingIndex = INDEX_NONE; LastHeadingPtr = nullptr; } // Add the separator next so that it will precede the heading if (LastSeparatorPtr && LastSeparatorIndex != INDEX_NONE) { FilteredComponentClassList.Insert(*LastSeparatorPtr, LastSeparatorIndex); LastSeparatorIndex = INDEX_NONE; LastSeparatorPtr = nullptr; } // Add the class FilteredComponentClassList.Add(CurrentEntry); } } } if (ComponentClassListView.IsValid()) { // Select the first non-category item that passed the filter for (FComponentClassComboEntryPtr& TestEntry : FilteredComponentClassList) { if (TestEntry->IsClass()) { ComponentClassListView->SetSelection(TestEntry, ESelectInfo::OnNavigation); break; } } } } FText SComponentClassCombo::GetCurrentSearchString() const { return TextFilter->GetFilterText(); } void SComponentClassCombo::OnSearchBoxTextChanged( const FText& InSearchText ) { TextFilter->SetFilterText(InSearchText); SearchBox->SetError(TextFilter->GetFilterErrorText()); UpdateComponentClassList(); } void SComponentClassCombo::OnSearchBoxTextCommitted(const FText& NewText, ETextCommit::Type CommitInfo) { if(CommitInfo == ETextCommit::OnEnter) { auto SelectedItems = ComponentClassListView->GetSelectedItems(); if(SelectedItems.Num() > 0) { ComponentClassListView->SetSelection(SelectedItems[0]); } } } // @todo: move this to FKismetEditorUtilities static UClass* GetAuthoritativeBlueprintClass(UBlueprint const* const Blueprint) { UClass* BpClass = (Blueprint->SkeletonGeneratedClass != nullptr) ? Blueprint->SkeletonGeneratedClass : Blueprint->GeneratedClass; if (BpClass == nullptr) { BpClass = Blueprint->ParentClass; } UClass* AuthoritativeClass = BpClass; if (BpClass != nullptr) { AuthoritativeClass = BpClass->GetAuthoritativeClass(); } return AuthoritativeClass; } void SComponentClassCombo::OnAddComponentSelectionChanged( FComponentClassComboEntryPtr InItem, ESelectInfo::Type SelectInfo ) { if ( InItem.IsValid() && InItem->IsClass() && SelectInfo != ESelectInfo::OnNavigation) { // We don't want the item to remain selected ClearSelection(); if ( InItem->IsClass() ) { // Neither do we want the combo dropdown staying open once the user has clicked on a valid option AddNewButton->SetIsMenuOpen(false, false); if( OnComponentClassSelected.IsBound() || OnSubobjectClassSelected.IsBound() ) { UClass* ComponentClass = InItem->GetComponentClass(); if (ComponentClass == nullptr) { // The class is not loaded yet, so load it: if (UObject* LoadedObject = LoadObject(nullptr, *InItem->GetComponentPath())) { if (UClass* LoadedClass = Cast(LoadedObject)) { ComponentClass = LoadedClass; } else if (UBlueprint* LoadedBP = Cast(LoadedObject)) { ComponentClass = GetAuthoritativeBlueprintClass(LoadedBP); } } } FSubobjectDataHandle NewActorCompHandle = OnSubobjectClassSelected.IsBound() ? OnSubobjectClassSelected.Execute(ComponentClass, InItem->GetComponentCreateAction(), InItem->GetAssetOverride()) : FSubobjectDataHandle::InvalidHandle; if(NewActorCompHandle.IsValid()) { InItem->GetOnSubobjectCreated().ExecuteIfBound(NewActorCompHandle); } } } } else if ( InItem.IsValid() && SelectInfo != ESelectInfo::OnMouseClick ) { int32 SelectedIdx = INDEX_NONE; if (FilteredComponentClassList.Find(InItem, /*out*/ SelectedIdx)) { if (!InItem->IsClass()) { int32 SelectionDirection = SelectedIdx - PrevSelectedIndex; // Update the previous selected index PrevSelectedIndex = SelectedIdx; // Make sure we select past the category header if we started filtering with it selected somehow (avoiding the infinite loop selecting the same item forever) if (SelectionDirection == 0) { SelectionDirection = 1; } if(SelectedIdx + SelectionDirection >= 0 && SelectedIdx + SelectionDirection < FilteredComponentClassList.Num()) { ComponentClassListView->SetSelection(FilteredComponentClassList[SelectedIdx + SelectionDirection], ESelectInfo::OnNavigation); } } else { // Update the previous selected index PrevSelectedIndex = SelectedIdx; } } } } TSharedRef SComponentClassCombo::GenerateAddComponentRow( FComponentClassComboEntryPtr Entry, const TSharedRef &OwnerTable ) const { check( Entry->IsHeading() || Entry->IsSeparator() || Entry->IsClass() ); if ( Entry->IsHeading() ) { return SNew( STableRow< TSharedPtr >, OwnerTable ) .Style(&FAppStyle::Get().GetWidgetStyle("TableView.NoHoverTableRow")) .ShowSelection(false) [ SNew(SBox) .Padding(1.f) [ SNew(STextBlock) .Text(FText::FromString(Entry->GetHeadingText())) .TextStyle(FAppStyle::Get(), TEXT("Menu.Heading")) ] ]; } else if ( Entry->IsSeparator() ) { return SNew( STableRow< TSharedPtr >, OwnerTable ) .Style(&FAppStyle::Get().GetWidgetStyle("TableView.NoHoverTableRow")) .ShowSelection(false) [ SNew(SSeparator) .SeparatorImage(FAppStyle::Get().GetBrush("Menu.Separator")) .Thickness(1.0f) ]; } else { return SNew( SComboRow< TSharedPtr >, OwnerTable ) .ToolTip( GetComponentToolTip(Entry) ) [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) [ SNew(SSpacer) .Size(FVector2D(8.0f,1.0f)) ] +SHorizontalBox::Slot() .Padding(1.0f) .AutoWidth() [ SNew(SImage) .Image( FSlateIconFinder::FindIconBrushForClass( Entry->GetIconOverrideBrushName() == NAME_None ? Entry->GetIconClass() : nullptr, Entry->GetIconOverrideBrushName() ) ) ] +SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) [ SNew(SSpacer) .Size(FVector2D(3.0f,1.0f)) ] +SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) [ SNew(STextBlock) .HighlightText(this, &SComponentClassCombo::GetCurrentSearchString) .Text(this, &SComponentClassCombo::GetFriendlyComponentName, Entry) ] ]; } } void SComponentClassCombo::UpdateComponentClassList() { // Regenerate the filtered list GenerateFilteredComponentList(); // Ask the combo to update its contents on next tick if (ComponentClassListView.IsValid()) { ComponentClassListView->RequestListRefresh(); } } FText SComponentClassCombo::GetFriendlyComponentName(FComponentClassComboEntryPtr Entry) const { // Get a user friendly string from the component name FString FriendlyComponentName; if( Entry->GetComponentCreateAction() == EComponentCreateAction::CreateNewCPPClass ) { FriendlyComponentName = LOCTEXT("NewCPPComponentFriendlyName", "New C++ Component...").ToString(); } else if (Entry->GetComponentCreateAction() == EComponentCreateAction::CreateNewBlueprintClass ) { FriendlyComponentName = LOCTEXT("NewBlueprintComponentFriendlyName", "New Blueprint Script Component...").ToString(); } else { FriendlyComponentName = GetSanitizedComponentName(Entry); // Don't try to match up assets for USceneComponent it will match lots of things and doesn't have any nice behavior for asset adds if (Entry->GetComponentClass() != USceneComponent::StaticClass() && Entry->GetComponentNameOverride().IsEmpty()) { // Search the selected assets and look for any that can be used as a source asset for this type of component // If there is one we append the asset name to the component name, if there are many we append "Multiple Assets" FString AssetName; UObject* PreviousMatchingAsset = NULL; FEditorDelegates::LoadSelectedAssetsIfNeeded.Broadcast(); USelection* Selection = GEditor->GetSelectedObjects(); for(FSelectionIterator ObjectIter(*Selection); ObjectIter; ++ObjectIter) { UObject* Object = *ObjectIter; check(Object); UClass* Class = Object->GetClass(); TArray > ComponentClasses = FComponentAssetBrokerage::GetComponentsForAsset(Object); for(int32 ComponentIndex = 0; ComponentIndex < ComponentClasses.Num(); ComponentIndex++) { if(ComponentClasses[ComponentIndex]->IsChildOf(Entry->GetComponentClass())) { if(AssetName.IsEmpty()) { // If there is no previous asset then we just accept the name AssetName = Object->GetName(); PreviousMatchingAsset = Object; } else { // if there is a previous asset then check that we didn't just find multiple appropriate components // in a single asset - if the asset differs then we don't display the name, just "Multiple Assets" if(PreviousMatchingAsset != Object) { AssetName = LOCTEXT("MultipleAssetsForComponentAnnotation", "Multiple Assets").ToString(); PreviousMatchingAsset = Object; } } } } } if(!AssetName.IsEmpty()) { FriendlyComponentName += FString(" (") + AssetName + FString(")"); } } } return FText::FromString(FriendlyComponentName); } FString SComponentClassCombo::GetSanitizedComponentName(FComponentClassComboEntryPtr Entry) { FString DisplayName; if (Entry->GetComponentNameOverride() != FString()) { DisplayName = Entry->GetComponentNameOverride(); } else if (UClass* ComponentClass = Entry->GetComponentClass()) { if (ComponentClass->HasMetaData(TEXT("DisplayName"))) { DisplayName = ComponentClass->GetMetaData(TEXT("DisplayName")); } else { DisplayName = ComponentClass->GetDisplayNameText().ToString(); if (!ComponentClass->HasAnyClassFlags(CLASS_CompiledFromBlueprint)) { DisplayName.RemoveFromEnd(TEXT("Component"), ESearchCase::IgnoreCase); } } } else { DisplayName = Entry->GetClassDisplayName(); } return FName::NameToDisplayString(DisplayName, false); } TSharedRef SComponentClassCombo::GetComponentToolTip(FComponentClassComboEntryPtr Entry) const { // Special handling for the "New..." options if (Entry->GetComponentCreateAction() == EComponentCreateAction::CreateNewCPPClass) { return SNew(SToolTip) .Text(LOCTEXT("NewCPPComponentToolTip", "Create a custom actor component using C++")); } else if (Entry->GetComponentCreateAction() == EComponentCreateAction::CreateNewBlueprintClass) { return SNew(SToolTip) .Text(LOCTEXT("NewBlueprintComponentToolTip", "Create a custom actor component using Blueprints")); } // Handle components which have a currently loaded class if (const UClass* ComponentClass = Entry->GetComponentClass()) { return FEditorClassUtils::GetTooltip(ComponentClass); } // Fallback for components that don't currently have a loaded class return SNew(SToolTip) .Text(FText::FromString(Entry->GetClassDisplayName())); } bool SComponentClassCombo::IsComponentClassAllowed(FComponentClassComboEntryPtr Entry) const { if (Entry.IsValid() && Entry->IsClass()) { check(ComponentClassFilterData.InitOptions.IsValid()); check(ComponentClassFilterData.ClassFilter.IsValid()); check(ComponentClassFilterData.FilterFuncs.IsValid()); if (const UClass* ComponentClass = Entry->GetComponentClass()) { return ComponentClassFilterData.ClassFilter->IsClassAllowed(*ComponentClassFilterData.InitOptions, ComponentClass, ComponentClassFilterData.FilterFuncs.ToSharedRef()); } else { TSharedPtr UnloadedBlueprintData = Entry->GetUnloadedBlueprintData(); if (UnloadedBlueprintData.IsValid()) { return ComponentClassFilterData.ClassFilter->IsUnloadedClassAllowed(*ComponentClassFilterData.InitOptions, UnloadedBlueprintData.ToSharedRef(), ComponentClassFilterData.FilterFuncs.ToSharedRef()); } } } // Allow all entries to otherwise pass by default. return true; } void SComponentClassCombo::GetComponentClassFilterOptions(TArray>& OutFilterOptions) const { if (ComponentClassFilterData.InitOptions.IsValid()) { TArray> FilterOptions; if(ComponentClassFilterData.ClassFilter.IsValid()) { ComponentClassFilterData.ClassFilter->GetFilterOptions(FilterOptions); OutFilterOptions.Append(FilterOptions); } for (const TSharedRef& ClassFilter : ComponentClassFilterData.InitOptions->ClassFilters) { FilterOptions.Reset(); ClassFilter->GetFilterOptions(FilterOptions); OutFilterOptions.Append(FilterOptions); } } } TSharedRef SComponentClassCombo::GetFilterOptionsMenuContent() { const bool bCloseSelfOnly = true; const bool bShouldCloseWindowAfterMenuSelection = true; FMenuBuilder MenuBuilder(bShouldCloseWindowAfterMenuSelection, /* InCommandList = */nullptr, /* InExtender = */nullptr, bCloseSelfOnly); TArray> ClassFilterOptions; GetComponentClassFilterOptions(ClassFilterOptions); if (ClassFilterOptions.Num() > 0) { MenuBuilder.BeginSection("ClassFilterOptions", LOCTEXT("ClassFilterOptionsHeading", "Class Filters")); { for (const TSharedRef& ClassFilterOption : ClassFilterOptions) { MenuBuilder.AddMenuEntry( ClassFilterOption->LabelText, ClassFilterOption->ToolTipText, FSlateIcon(), FUIAction( FExecuteAction::CreateSP(this, &SComponentClassCombo::ToggleFilterOption, ClassFilterOption), FCanExecuteAction(), FIsActionChecked::CreateSP(this, &SComponentClassCombo::IsFilterOptionEnabled, ClassFilterOption) ), NAME_None, EUserInterfaceActionType::ToggleButton ); } } MenuBuilder.EndSection(); } return MenuBuilder.MakeWidget(); } void SComponentClassCombo::ToggleFilterOption(TSharedRef FilterOption) { FilterOption->bEnabled = !FilterOption->bEnabled; if (FilterOption->OnOptionChanged.IsBound()) { FilterOption->OnOptionChanged.Execute(FilterOption->bEnabled); } UpdateComponentClassList(); } bool SComponentClassCombo::IsFilterOptionEnabled(TSharedRef FilterOption) const { return FilterOption->bEnabled; } EVisibility SComponentClassCombo::GetFilterOptionsButtonVisibility() const { TArray> FilterOptions; GetComponentClassFilterOptions(FilterOptions); return FilterOptions.Num() > 0 ? EVisibility::Visible : EVisibility::Collapsed; } #undef LOCTEXT_NAMESPACE