// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. #include "SGraphActionMenu.h" #include "Framework/Application/SlateApplication.h" #include "Widgets/Text/SRichTextBlock.h" #include "Widgets/Layout/SScrollBorder.h" #include "EditorStyleSet.h" #include "Styling/CoreStyle.h" #include "GraphEditorDragDropAction.h" #include "EdGraphSchema_K2.h" #include "K2Node.h" #include "EdGraphSchema_K2_Actions.h" #include "GraphActionNode.h" #include "Widgets/SToolTip.h" #include "IDocumentation.h" #include "EditorCategoryUtils.h" #include "Widgets/Input/SEditableTextBox.h" #include "Widgets/Input/SSearchBox.h" #include "Widgets/Text/SInlineEditableTextBlock.h" #define LOCTEXT_NAMESPACE "GraphActionMenu" ////////////////////////////////////////////////////////////////////////// template class SCategoryHeaderTableRow : public STableRow < ItemType > { public: SLATE_BEGIN_ARGS(SCategoryHeaderTableRow) {} SLATE_DEFAULT_SLOT(typename SCategoryHeaderTableRow::FArguments, Content) SLATE_END_ARGS() void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTableView) { STableRow::ChildSlot .Padding(0.0f, 2.0f, 0.0f, 0.0f) [ SAssignNew(ContentBorder, SBorder) .BorderImage(this, &SCategoryHeaderTableRow::GetBackgroundImage) .Padding(FMargin(0.0f, 3.0f)) .BorderBackgroundColor(FLinearColor(.6, .6, .6, 1.0f)) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .VAlign(VAlign_Center) .Padding(2.0f, 2.0f, 2.0f, 2.0f) .AutoWidth() [ SNew(SExpanderArrow, STableRow< ItemType >::SharedThis(this)) ] + SHorizontalBox::Slot() .VAlign(VAlign_Center) [ InArgs._Content.Widget ] ] ]; STableRow < ItemType >::ConstructInternal( typename STableRow< ItemType >::FArguments() .Style(FEditorStyle::Get(), "DetailsView.TreeView.TableRow") .ShowSelection(false), InOwnerTableView ); } const FSlateBrush* GetBackgroundImage() const { if ( STableRow::IsHovered() ) { return STableRow::IsItemExpanded() ? FEditorStyle::GetBrush("DetailsView.CategoryTop_Hovered") : FEditorStyle::GetBrush("DetailsView.CollapsedCategory_Hovered"); } else { return STableRow::IsItemExpanded() ? FEditorStyle::GetBrush("DetailsView.CategoryTop") : FEditorStyle::GetBrush("DetailsView.CollapsedCategory"); } } virtual void SetContent(TSharedRef< SWidget > InContent) override { ContentBorder->SetContent(InContent); } virtual void SetRowContent(TSharedRef< SWidget > InContent) override { ContentBorder->SetContent(InContent); } virtual const FSlateBrush* GetBorder() const { return nullptr; } private: TSharedPtr ContentBorder; }; ////////////////////////////////////////////////////////////////////////// namespace GraphActionMenuHelpers { bool ActionMatchesName(const FEdGraphSchemaAction* InGraphAction, const FName& ItemName) { bool bCheck = false; bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Var::StaticGetTypeId() && ((FEdGraphSchemaAction_K2Var*)InGraphAction)->GetVariableName() == ItemName); bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2LocalVar::StaticGetTypeId() && ((FEdGraphSchemaAction_K2LocalVar*)InGraphAction)->GetVariableName() == ItemName); bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Graph::StaticGetTypeId() && ((FEdGraphSchemaAction_K2Graph*)InGraphAction)->EdGraph && ((FEdGraphSchemaAction_K2Graph*)InGraphAction)->EdGraph->GetFName() == ItemName); bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Enum::StaticGetTypeId() && ((FEdGraphSchemaAction_K2Enum*)InGraphAction)->GetPathName() == ItemName); bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Struct::StaticGetTypeId() && ((FEdGraphSchemaAction_K2Struct*)InGraphAction)->GetPathName() == ItemName); bCheck |= (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Delegate::StaticGetTypeId() && ((FEdGraphSchemaAction_K2Delegate*)InGraphAction)->GetDelegateName() == ItemName); const bool bIsTargetNodeSubclass = (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2TargetNode::StaticGetTypeId()) || (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2Event::StaticGetTypeId()) || (InGraphAction->GetTypeId() == FEdGraphSchemaAction_K2InputAction::StaticGetTypeId()); bCheck |= (bIsTargetNodeSubclass && ((FEdGraphSchemaAction_K2TargetNode*)InGraphAction)->NodeTemplate->GetNodeTitle(ENodeTitleType::EditableTitle).ToString() == ItemName.ToString()); return bCheck; } } ////////////////////////////////////////////////////////////////////////// void SDefaultGraphActionWidget::Construct(const FArguments& InArgs, const FCreateWidgetForActionData* InCreateData) { ActionPtr = InCreateData->Action; MouseButtonDownDelegate = InCreateData->MouseButtonDownDelegate; this->ChildSlot [ SNew(SHorizontalBox) .ToolTipText(InCreateData->Action->GetTooltipDescription()) + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) [ SNew(STextBlock) .Font(FCoreStyle::GetDefaultFontStyle("Regular", 9)) .Text(InCreateData->Action->GetMenuDescription()) .HighlightText(InArgs._HighlightText) ] ]; } FReply SDefaultGraphActionWidget::OnMouseButtonDown( const FGeometry& MyGeometry, const FPointerEvent& MouseEvent ) { if( MouseButtonDownDelegate.Execute( ActionPtr ) ) { return FReply::Handled(); } return FReply::Unhandled(); } ////////////////////////////////////////////////////////////////////////// class SGraphActionCategoryWidget : public SCompoundWidget { SLATE_BEGIN_ARGS( SGraphActionCategoryWidget ) {} SLATE_ATTRIBUTE( FText, HighlightText ) SLATE_EVENT( FOnTextCommitted, OnTextCommitted ) SLATE_EVENT( FIsSelected, IsSelected ) SLATE_ATTRIBUTE( bool, IsReadOnly ) SLATE_END_ARGS() TWeakPtr ActionNode; TAttribute IsReadOnly; public: TWeakPtr InlineWidget; void Construct( const FArguments& InArgs, TSharedPtr InActionNode ) { ActionNode = InActionNode; FText CategoryTooltip; FString CategoryLink, CategoryExcerpt; FEditorCategoryUtils::GetCategoryTooltipInfo(*InActionNode->GetDisplayName().ToString(), CategoryTooltip, CategoryLink, CategoryExcerpt); TSharedRef ToolTipWidget = IDocumentation::Get()->CreateToolTip(CategoryTooltip, NULL, CategoryLink, CategoryExcerpt); IsReadOnly = InArgs._IsReadOnly; this->ChildSlot [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .VAlign(VAlign_Center) [ SAssignNew(InlineWidget, SInlineEditableTextBlock) .Font( FCoreStyle::GetDefaultFontStyle("Bold", 9) ) .Text( FEditorCategoryUtils::GetCategoryDisplayString(InActionNode->GetDisplayName()) ) .ToolTip( ToolTipWidget ) .HighlightText( InArgs._HighlightText ) .OnVerifyTextChanged( this, &SGraphActionCategoryWidget::OnVerifyTextChanged ) .OnTextCommitted( InArgs._OnTextCommitted ) .IsSelected( InArgs._IsSelected ) .IsReadOnly( InArgs._IsReadOnly ) ] ]; } // SWidget interface virtual FReply OnDrop( const FGeometry& MyGeometry, const FDragDropEvent& DragDropEvent ) override { TSharedPtr GraphDropOp = DragDropEvent.GetOperationAs(); if (GraphDropOp.IsValid()) { GraphDropOp->DroppedOnCategory( ActionNode.Pin()->GetCategoryPath() ); return FReply::Handled(); } return FReply::Unhandled(); } virtual void OnDragEnter( const FGeometry& MyGeometry, const FDragDropEvent& DragDropEvent ) override { TSharedPtr GraphDropOp = DragDropEvent.GetOperationAs(); if (GraphDropOp.IsValid()) { GraphDropOp->SetHoveredCategoryName( ActionNode.Pin()->GetDisplayName() ); } } virtual void OnDragLeave( const FDragDropEvent& DragDropEvent ) override { TSharedPtr GraphDropOp = DragDropEvent.GetOperationAs(); if (GraphDropOp.IsValid()) { GraphDropOp->SetHoveredCategoryName( FText::GetEmpty() ); } } // End of SWidget interface /** Callback for the SInlineEditableTextBlock to verify the text before commit */ bool OnVerifyTextChanged(const FText& InText, FText& OutErrorMessage) { if(InText.ToString().Len() > NAME_SIZE) { OutErrorMessage = LOCTEXT("CategoryNameTooLong_Error", "Name too long!"); return false; } return true; } }; ////////////////////////////////////////////////////////////////////////// void SGraphActionMenu::Construct( const FArguments& InArgs, bool bIsReadOnly/* = true*/ ) { this->SelectedSuggestion = INDEX_NONE; this->bIgnoreUIUpdate = false; this->bUseSectionStyling = InArgs._UseSectionStyling; this->bAutoExpandActionMenu = InArgs._AutoExpandActionMenu; this->bShowFilterTextBox = InArgs._ShowFilterTextBox; this->bAlphaSortItems = InArgs._AlphaSortItems; this->OnActionSelected = InArgs._OnActionSelected; this->OnActionDoubleClicked = InArgs._OnActionDoubleClicked; this->OnActionDragged = InArgs._OnActionDragged; this->OnCategoryDragged = InArgs._OnCategoryDragged; this->OnCreateWidgetForAction = InArgs._OnCreateWidgetForAction; this->OnCreateCustomRowExpander = InArgs._OnCreateCustomRowExpander; this->OnCollectAllActions = InArgs._OnCollectAllActions; this->OnCollectStaticSections = InArgs._OnCollectStaticSections; this->OnCategoryTextCommitted = InArgs._OnCategoryTextCommitted; this->OnCanRenameSelectedAction = InArgs._OnCanRenameSelectedAction; this->OnGetSectionTitle = InArgs._OnGetSectionTitle; this->OnGetSectionToolTip = InArgs._OnGetSectionToolTip; this->OnGetSectionWidget = InArgs._OnGetSectionWidget; this->FilteredRootAction = FGraphActionNode::NewRootNode(); this->OnActionMatchesName = InArgs._OnActionMatchesName; // If a delegate for filtering text is passed in, assign it so that it will be used instead of the built-in filter box if(InArgs._OnGetFilterText.IsBound()) { this->OnGetFilterText = InArgs._OnGetFilterText; } TreeView = SNew(STreeView< TSharedPtr >) .ItemHeight(24) .TreeItemsSource(&(this->FilteredRootAction->Children)) .OnGenerateRow(this, &SGraphActionMenu::MakeWidget, bIsReadOnly) .OnSelectionChanged(this, &SGraphActionMenu::OnItemSelected) .OnMouseButtonDoubleClick(this, &SGraphActionMenu::OnItemDoubleClicked) .OnContextMenuOpening(InArgs._OnContextMenuOpening) .OnGetChildren(this, &SGraphActionMenu::OnGetChildrenForCategory) .SelectionMode(ESelectionMode::Single) .OnItemScrolledIntoView(this, &SGraphActionMenu::OnItemScrolledIntoView) .OnSetExpansionRecursive(this, &SGraphActionMenu::OnSetExpansionRecursive) .HighlightParentNodesForSelection(true); this->ChildSlot [ SNew(SVerticalBox) // FILTER BOX +SVerticalBox::Slot() .AutoHeight() [ SAssignNew(FilterTextBox, SSearchBox) // If there is an external filter delegate, do not display this filter box .Visibility(InArgs._OnGetFilterText.IsBound()? EVisibility::Collapsed : EVisibility::Visible) .OnTextChanged( this, &SGraphActionMenu::OnFilterTextChanged ) .OnTextCommitted( this, &SGraphActionMenu::OnFilterTextCommitted ) ] // ACTION LIST +SVerticalBox::Slot() .Padding(FMargin(0.0f, 2.0f, 0.0f, 0.0f)) .FillHeight(1.f) [ SNew(SScrollBorder, TreeView.ToSharedRef()) [ TreeView.ToSharedRef() ] ] ]; // When the search box has focus, we want first chance handling of any key down events so we can handle the up/down and escape keys the way we want FilterTextBox->SetOnKeyDownHandler(FOnKeyDown::CreateSP(this, &SGraphActionMenu::OnKeyDown)); if (!InArgs._ShowFilterTextBox) { FilterTextBox->SetVisibility(EVisibility::Collapsed); } // Get all actions. RefreshAllActions(false); } void SGraphActionMenu::RefreshAllActions(bool bPreserveExpansion, bool bHandleOnSelectionEvent/*=true*/) { // Save Selection (of only the first selected thing) TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); TSharedPtr SelectedAction = SelectedNodes.Num() > 0 ? SelectedNodes[0] : nullptr; AllActions.Empty(); OnCollectAllActions.ExecuteIfBound(AllActions); GenerateFilteredItems(bPreserveExpansion); // Re-apply selection #0 if possible if (SelectedAction.IsValid()) { // Clear the selection, we will be re-selecting the previous action TreeView->ClearSelection(); if(bHandleOnSelectionEvent) { SelectItemByName(*SelectedAction->GetDisplayName().ToString(), ESelectInfo::OnMouseClick, SelectedAction->SectionID, SelectedNodes[0]->IsCategoryNode()); } else { // If we do not want to handle the selection, set it directly so it will reselect the item but not handle the event. SelectItemByName(*SelectedAction->GetDisplayName().ToString(), ESelectInfo::Direct, SelectedAction->SectionID, SelectedNodes[0]->IsCategoryNode()); } } } void SGraphActionMenu::GetSectionExpansion(TMap& SectionExpansion) const { } void SGraphActionMenu::SetSectionExpansion(const TMap& InSectionExpansion) { for ( auto& PossibleSection : FilteredRootAction->Children ) { if ( PossibleSection->IsSectionHeadingNode() ) { const bool* IsExpanded = InSectionExpansion.Find(PossibleSection->SectionID); if ( IsExpanded != nullptr ) { TreeView->SetItemExpansion(PossibleSection, *IsExpanded); } } } } TSharedRef SGraphActionMenu::GetFilterTextBox() { return FilterTextBox.ToSharedRef(); } void SGraphActionMenu::GetSelectedActions(TArray< TSharedPtr >& OutSelectedActions) const { OutSelectedActions.Empty(); TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); if(SelectedNodes.Num() > 0) { for ( int32 NodeIndex = 0; NodeIndex < SelectedNodes.Num(); NodeIndex++ ) { OutSelectedActions.Append( SelectedNodes[NodeIndex]->Actions ); } } } void SGraphActionMenu::OnRequestRenameOnActionNode() { TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); if(SelectedNodes.Num() > 0) { if (!SelectedNodes[0]->BroadcastRenameRequest()) { TreeView->RequestScrollIntoView(SelectedNodes[0]); } } } bool SGraphActionMenu::CanRequestRenameOnActionNode() const { TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); if(SelectedNodes.Num() == 1 && OnCanRenameSelectedAction.IsBound()) { return OnCanRenameSelectedAction.Execute(SelectedNodes[0]); } return false; } FString SGraphActionMenu::GetSelectedCategoryName() const { TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); return (SelectedNodes.Num() > 0) ? SelectedNodes[0]->GetDisplayName().ToString() : FString(); } void SGraphActionMenu::GetSelectedCategorySubActions(TArray>& OutActions) const { TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); for ( int32 SelectionIndex = 0; SelectionIndex < SelectedNodes.Num(); SelectionIndex++ ) { if ( SelectedNodes[SelectionIndex].IsValid() ) { GetCategorySubActions(SelectedNodes[SelectionIndex], OutActions); } } } void SGraphActionMenu::GetCategorySubActions(TWeakPtr InAction, TArray>& OutActions) const { if(InAction.IsValid()) { TSharedPtr CategoryNode = InAction.Pin(); TArray> Children; CategoryNode->GetLeafNodes(Children); for (int32 i = 0; i < Children.Num(); ++i) { TSharedPtr CurrentChild = Children[i]; if (CurrentChild.IsValid() && CurrentChild->IsActionNode()) { for ( int32 ActionIndex = 0; ActionIndex != CurrentChild->Actions.Num(); ActionIndex++ ) { OutActions.Add(CurrentChild->Actions[ActionIndex]); } } } } } bool SGraphActionMenu::SelectItemByName(const FName& ItemName, ESelectInfo::Type SelectInfo, int32 SectionId/* = INDEX_NONE */, bool bIsCategory/* = false*/) { if (ItemName != NAME_None) { TSharedPtr SelectionNode; TArray> GraphNodes; FilteredRootAction->GetAllNodes(GraphNodes); for (int32 i = 0; i < GraphNodes.Num() && !SelectionNode.IsValid(); ++i) { TSharedPtr CurrentGraphNode = GraphNodes[i]; FEdGraphSchemaAction* GraphAction = CurrentGraphNode->GetPrimaryAction().Get(); // If the user is attempting to select a category, make sure it's a category if( CurrentGraphNode->IsCategoryNode() == bIsCategory ) { if(SectionId == INDEX_NONE || CurrentGraphNode->SectionID == SectionId) { if (GraphAction) { if ((OnActionMatchesName.IsBound() && OnActionMatchesName.Execute(GraphAction, ItemName)) || GraphActionMenuHelpers::ActionMatchesName(GraphAction, ItemName)) { SelectionNode = GraphNodes[i]; break; } } if (CurrentGraphNode->GetDisplayName().ToString() == FName::NameToDisplayString(ItemName.ToString(), false)) { SelectionNode = CurrentGraphNode; break; } } } // One of the children may match for(int32 ChildIdx = 0; ChildIdx < CurrentGraphNode->Children.Num() && !SelectionNode.IsValid(); ++ChildIdx) { TSharedPtr CurrentChildNode = CurrentGraphNode->Children[ChildIdx]; for ( int32 ActionIndex = 0; ActionIndex < CurrentChildNode->Actions.Num(); ActionIndex++ ) { FEdGraphSchemaAction* ChildGraphAction = CurrentChildNode->Actions[ActionIndex].Get(); // If the user is attempting to select a category, make sure it's a category if( CurrentChildNode->IsCategoryNode() == bIsCategory ) { if(SectionId == INDEX_NONE || CurrentChildNode->SectionID == SectionId) { if(ChildGraphAction) { if ((OnActionMatchesName.IsBound() && OnActionMatchesName.Execute(ChildGraphAction, ItemName)) || GraphActionMenuHelpers::ActionMatchesName(ChildGraphAction, ItemName)) { SelectionNode = GraphNodes[i]->Children[ChildIdx]; break; } } else if (CurrentChildNode->GetDisplayName().ToString() == FName::NameToDisplayString(ItemName.ToString(), false)) { SelectionNode = CurrentChildNode; break; } } } } } } if(SelectionNode.IsValid()) { // Expand the parent nodes for (TSharedPtr ParentAction = SelectionNode->GetParentNode().Pin(); ParentAction.IsValid(); ParentAction = ParentAction->GetParentNode().Pin()) { TreeView->SetItemExpansion(ParentAction, true); } // Select the node TreeView->SetSelection(SelectionNode,SelectInfo); TreeView->RequestScrollIntoView(SelectionNode); return true; } } else { TreeView->ClearSelection(); return true; } return false; } void SGraphActionMenu::ExpandCategory(const FText& CategoryName) { if (!CategoryName.IsEmpty()) { TArray> GraphNodes; FilteredRootAction->GetAllNodes(GraphNodes); for (int32 i = 0; i < GraphNodes.Num(); ++i) { if (GraphNodes[i]->GetDisplayName().EqualTo(CategoryName)) { GraphNodes[i]->ExpandAllChildren(TreeView); } } } } static bool CompareGraphActionNode(TSharedPtr A, TSharedPtr B) { check(A.IsValid()); check(B.IsValid()); // First check grouping is the same if (A->GetDisplayName().ToString() != B->GetDisplayName().ToString()) { return false; } if (A->SectionID != B->SectionID) { return false; } if (A->HasValidAction() && B->HasValidAction()) { return A->GetPrimaryAction()->GetMenuDescription().CompareTo(B->GetPrimaryAction()->GetMenuDescription()) == 0; } else if(!A->HasValidAction() && !B->HasValidAction()) { return true; } else { return false; } } template void RestoreExpansionState(TSharedPtr< STreeView > InTree, const TArray& ItemSource, const TSet& OldExpansionState, ComparisonType ComparisonFunction) { check(InTree.IsValid()); // Iterate over new tree items for(int32 ItemIdx=0; ItemIdx::TConstIterator OldExpansionIter(OldExpansionState); OldExpansionIter; ++OldExpansionIter) { const ItemType OldItem = *OldExpansionIter; // See if this matches this new item if(ComparisonFunction(OldItem, NewItem)) { // It does, so expand it InTree->SetItemExpansion(NewItem, true); } } } } void SGraphActionMenu::GenerateFilteredItems(bool bPreserveExpansion) { // First, save off current expansion state TSet< TSharedPtr > OldExpansionState; if(bPreserveExpansion) { TreeView->GetExpandedItems(OldExpansionState); } // Clear the filtered root action FilteredRootAction->ClearChildren(); // Collect the list of always visible sections if any, and force the creation of those sections. if ( OnCollectStaticSections.IsBound() ) { TArray StaticSectionIDs; OnCollectStaticSections.Execute(StaticSectionIDs); for ( int32 i = 0; i < StaticSectionIDs.Num(); i++ ) { FilteredRootAction->AddSection(0, StaticSectionIDs[i]); } } // Trim and sanitized the filter text (so that it more likely matches the action descriptions) FString TrimmedFilterString = FText::TrimPrecedingAndTrailing(GetFilterText()).ToString(); // Tokenize the search box text into a set of terms; all of them must be present to pass the filter TArray FilterTerms; TrimmedFilterString.ParseIntoArray(FilterTerms, TEXT(" "), true); for (auto& String : FilterTerms) { String = String.ToLower(); } // Generate a list of sanitized versions of the strings TArray SanitizedFilterTerms; for (int32 iFilters = 0; iFilters < FilterTerms.Num() ; iFilters++) { FString EachString = FName::NameToDisplayString( FilterTerms[iFilters], false ); EachString = EachString.Replace( TEXT( " " ), TEXT( "" ) ); SanitizedFilterTerms.Add( EachString ); } ensure( SanitizedFilterTerms.Num() == FilterTerms.Num() );// Both of these should match ! const bool bRequiresFiltering = FilterTerms.Num() > 0; int32 BestMatchCount = 0; int32 BestMatchIndex = INDEX_NONE; for (int32 CurTypeIndex=0; CurTypeIndex < AllActions.GetNumActions(); ++CurTypeIndex) { FGraphActionListBuilderBase::ActionGroup& CurrentAction = AllActions.GetAction( CurTypeIndex ); // If we're filtering, search check to see if we need to show this action bool bShowAction = true; int32 EachWeight = 0; if (bRequiresFiltering) { // Combine the actions string, separate with \n so terms don't run into each other, and remove the spaces (incase the user is searching for a variable) // In the case of groups containing multiple actions, they will have been created and added at the same place in the code, using the same description // and keywords, so we only need to use the first one for filtering. const FString& SearchText = CurrentAction.GetSearchTextForFirstAction(); FString EachTermSanitized; for (int32 FilterIndex = 0; (FilterIndex < FilterTerms.Num()) && bShowAction; ++FilterIndex) { const bool bMatchesTerm = (SearchText.Contains(FilterTerms[FilterIndex], ESearchCase::CaseSensitive) || (SearchText.Contains(SanitizedFilterTerms[FilterIndex], ESearchCase::CaseSensitive) == true)); bShowAction = bShowAction && bMatchesTerm; } // Only if we are going to show the action do we want to generate the weight of the filter text if (bShowAction) { // Get the 'weight' of this in relation to the filter EachWeight = GetActionFilteredWeight( CurrentAction, FilterTerms, SanitizedFilterTerms ); } } if (bShowAction) { // If this action has a greater relevance than others, cache its index. if( EachWeight > BestMatchCount ) { BestMatchCount = EachWeight; BestMatchIndex = CurTypeIndex; } FilteredRootAction->AddChild(CurrentAction); } } FilteredRootAction->SortChildren(bAlphaSortItems, /*bRecursive =*/true); TreeView->RequestTreeRefresh(); // Update the filtered list (needs to be done in a separate pass because the list is sorted as items are inserted) FilteredActionNodes.Empty(); FilteredRootAction->GetLeafNodes(FilteredActionNodes); // Get _all_ new nodes (flattened tree basically) TArray< TSharedPtr > AllNodes; FilteredRootAction->GetAllNodes(AllNodes); // If theres a BestMatchIndex find it in the actions nodes and select it (maybe this should check the current selected suggestion first ?) if( BestMatchIndex != INDEX_NONE ) { FGraphActionListBuilderBase::ActionGroup& FilterSelectAction = AllActions.GetAction( BestMatchIndex ); if( FilterSelectAction.Actions[0].IsValid() == true ) { for (int32 iNode = 0; iNode < FilteredActionNodes.Num() ; iNode++) { if( FilteredActionNodes[ iNode ].Get()->GetPrimaryAction() == FilterSelectAction.Actions[ 0 ] ) { SelectedSuggestion = iNode; } } } } // Make sure the selected suggestion stays within the filtered list if ((SelectedSuggestion >= 0) && (FilteredActionNodes.Num() > 0)) { //@TODO: Should try to actually maintain the highlight on the same item if it survived the filtering SelectedSuggestion = FMath::Clamp(SelectedSuggestion, 0, FilteredActionNodes.Num() - 1); MarkActiveSuggestion(); } else { SelectedSuggestion = INDEX_NONE; } if (ShouldExpandNodes()) { // Expand all FilteredRootAction->ExpandAllChildren(TreeView); } else { // Expand to match the old state RestoreExpansionState< TSharedPtr >(TreeView, AllNodes, OldExpansionState, CompareGraphActionNode); } } int32 SGraphActionMenu::GetActionFilteredWeight( const FGraphActionListBuilderBase::ActionGroup& InCurrentAction, const TArray& InFilterTerms, const TArray& InSanitizedFilterTerms ) { // The overall 'weight' int32 TotalWeight = 0; // Some simple weight figures to help find the most appropriate match const int32 WholeMatchWeightMultiplier = 2; const int32 WholeMatchLocalizedWeightMultiplier = 3; const int32 DescriptionWeight = 10; const int32 CategoryWeight = 1; const int32 NodeTitleWeight = 1; const int32 KeywordWeight = 4; // Helper array struct FArrayWithWeight { FArrayWithWeight(const TArray< FString >* InArray, int32 InWeight) : Array(InArray) , Weight(InWeight) { } const TArray< FString >* Array; int32 Weight; }; // Setup an array of arrays so we can do a weighted search TArray< FArrayWithWeight > WeightedArrayList; int32 Action = 0; if( InCurrentAction.Actions[Action].IsValid() == true ) { // Combine the actions string, separate with \n so terms don't run into each other, and remove the spaces (incase the user is searching for a variable) // In the case of groups containing multiple actions, they will have been created and added at the same place in the code, using the same description // and keywords, so we only need to use the first one for filtering. const FString& SearchText = InCurrentAction.GetSearchTextForFirstAction(); // First the localized keywords WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetLocalizedSearchKeywordsArrayForFirstAction(), KeywordWeight)); // The localized description WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetLocalizedMenuDescriptionArrayForFirstAction(), DescriptionWeight)); // The node search localized title weight WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetLocalizedSearchTitleArrayForFirstAction(), NodeTitleWeight)); // The localized category WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetLocalizedSearchCategoryArrayForFirstAction(), CategoryWeight)); // First the keywords int32 NonLocalizedFirstIndex = WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetSearchKeywordsArrayForFirstAction(), KeywordWeight)); // The description WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetMenuDescriptionArrayForFirstAction(), DescriptionWeight)); // The node search title weight WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetSearchTitleArrayForFirstAction(), NodeTitleWeight)); // The category WeightedArrayList.Add(FArrayWithWeight(&InCurrentAction.GetSearchCategoryArrayForFirstAction(), CategoryWeight)); // Now iterate through all the filter terms and calculate a 'weight' using the values and multipliers const FString* EachTerm = nullptr; const FString* EachTermSanitized = nullptr; for (int32 FilterIndex = 0; FilterIndex < InFilterTerms.Num(); ++FilterIndex) { EachTerm = &InFilterTerms[FilterIndex]; EachTermSanitized = &InSanitizedFilterTerms[FilterIndex]; if( SearchText.Contains( *EachTerm, ESearchCase::CaseSensitive ) ) { TotalWeight += 2; } else if (SearchText.Contains(*EachTermSanitized, ESearchCase::CaseSensitive)) { TotalWeight++; } // Now check the weighted lists (We could further improve the hit weight by checking consecutive word matches) for (int32 iFindCount = 0; iFindCount < WeightedArrayList.Num() ; iFindCount++) { int32 WeightPerList = 0; const TArray& KeywordArray = *WeightedArrayList[iFindCount].Array; int32 EachWeight = WeightedArrayList[iFindCount].Weight; int32 WholeMatchCount = 0; int32 WholeMatchMultiplier = (iFindCount < NonLocalizedFirstIndex) ? WholeMatchLocalizedWeightMultiplier : WholeMatchWeightMultiplier; for (int32 iEachWord = 0; iEachWord < KeywordArray.Num() ; iEachWord++) { // If we get an exact match weight the find count to get exact matches higher priority if (KeywordArray[iEachWord].StartsWith(*EachTerm, ESearchCase::CaseSensitive)) { if (iEachWord == 0) { WeightPerList += EachWeight * WholeMatchMultiplier; } else { WeightPerList += EachWeight; } WholeMatchCount++; } else if (KeywordArray[iEachWord].Contains(*EachTerm, ESearchCase::CaseSensitive)) { WeightPerList += EachWeight; } if (KeywordArray[iEachWord].StartsWith(*EachTermSanitized, ESearchCase::CaseSensitive)) { if (iEachWord == 0) { WeightPerList += EachWeight * WholeMatchMultiplier; } else { WeightPerList += EachWeight; } WholeMatchCount++; } else if (KeywordArray[iEachWord].Contains(*EachTermSanitized, ESearchCase::CaseSensitive)) { WeightPerList += EachWeight / 2; } } // Increase the weight if theres a larger % of matches in the keyword list if( WholeMatchCount != 0 ) { int32 PercentAdjust = ( 100 / KeywordArray.Num() ) * WholeMatchCount; WeightPerList *= PercentAdjust; } TotalWeight += WeightPerList; } } } return TotalWeight; } // Returns true if the tree should be autoexpanded bool SGraphActionMenu::ShouldExpandNodes() const { // Expand all the categories that have filter results, or when there are only a few to show const bool bFilterActive = !GetFilterText().IsEmpty(); const bool bOnlyAFewTotal = AllActions.GetNumActions() < 10; return bFilterActive || bOnlyAFewTotal || bAutoExpandActionMenu; } bool SGraphActionMenu::CanRenameNode(TWeakPtr InNode) const { return !OnCanRenameSelectedAction.Execute(InNode); } void SGraphActionMenu::OnFilterTextChanged( const FText& InFilterText ) { // Reset the selection if the string is empty if( InFilterText.IsEmpty() == true ) { SelectedSuggestion = INDEX_NONE; } GenerateFilteredItems(false); } void SGraphActionMenu::OnFilterTextCommitted(const FText& InText, ETextCommit::Type CommitInfo) { if (CommitInfo == ETextCommit::OnEnter) { TryToSpawnActiveSuggestion(); } } bool SGraphActionMenu::TryToSpawnActiveSuggestion() { TArray< TSharedPtr > SelectionList = TreeView->GetSelectedItems(); if (SelectionList.Num() == 1) { // This isnt really a keypress - its Direct, but its always called from a keypress function. (Maybe pass the selectinfo in ?) OnItemSelected( SelectionList[0], ESelectInfo::OnKeyPress ); return true; } else if (FilteredActionNodes.Num() == 1) { OnItemSelected( FilteredActionNodes[0], ESelectInfo::OnKeyPress ); return true; } return false; } void SGraphActionMenu::OnGetChildrenForCategory( TSharedPtr InItem, TArray< TSharedPtr >& OutChildren ) { if (InItem->Children.Num()) { OutChildren = InItem->Children; } } void SGraphActionMenu::OnNameTextCommitted(const FText& NewText, ETextCommit::Type InTextCommit, TWeakPtr< FGraphActionNode > InAction ) { if(OnCategoryTextCommitted.IsBound()) { OnCategoryTextCommitted.Execute(NewText, InTextCommit, InAction); } } void SGraphActionMenu::OnItemScrolledIntoView( TSharedPtr InActionNode, const TSharedPtr& InWidget ) { if (InActionNode->IsRenameRequestPending()) { InActionNode->BroadcastRenameRequest(); } } TSharedRef SGraphActionMenu::MakeWidget( TSharedPtr InItem, const TSharedRef& OwnerTable, bool bIsReadOnly ) { TSharedPtr SectionToolTip; if ( InItem->IsSectionHeadingNode() ) { if ( OnGetSectionToolTip.IsBound() ) { SectionToolTip = OnGetSectionToolTip.Execute(InItem->SectionID); } } // In the case of FGraphActionNodes that have multiple actions, all of the actions will // have the same text as they will have been created at the same point - only the actual // action itself will differ, which is why parts of this function only refer to InItem->Actions[0] // rather than iterating over the array // Create the widget but do not add any content, the widget is needed to pass the IsSelectedExclusively function down to the potential SInlineEditableTextBlock widget TSharedPtr< STableRow< TSharedPtr > > TableRow; if ( InItem->IsSectionHeadingNode() ) { TableRow = SNew(SCategoryHeaderTableRow< TSharedPtr >, OwnerTable) .ToolTip(SectionToolTip); } else { const FTableRowStyle* Style = bUseSectionStyling ? &FEditorStyle::Get().GetWidgetStyle("TableView.DarkRow") : &FCoreStyle::Get().GetWidgetStyle("TableView.Row"); TableRow = SNew(STableRow< TSharedPtr >, OwnerTable) .Style(Style) .OnDragDetected(this, &SGraphActionMenu::OnItemDragDetected) .ShowSelection(!InItem->IsSeparator()); } TSharedPtr RowContainer; TableRow->SetRowContent ( SAssignNew(RowContainer, SHorizontalBox) ); TSharedPtr RowContent; FMargin RowPadding = FMargin(0, 2); if( InItem->IsActionNode() ) { check(InItem->HasValidAction()); FCreateWidgetForActionData CreateData(&InItem->OnRenameRequest()); CreateData.Action = InItem->GetPrimaryAction(); CreateData.HighlightText = TAttribute(this, &SGraphActionMenu::GetFilterText); CreateData.MouseButtonDownDelegate = FCreateWidgetMouseButtonDown::CreateSP( this, &SGraphActionMenu::OnMouseButtonDownEvent ); if(OnCreateWidgetForAction.IsBound()) { CreateData.IsRowSelectedDelegate = FIsSelected::CreateSP( TableRow.Get(), &STableRow< TSharedPtr >::IsSelected ); CreateData.bIsReadOnly = bIsReadOnly; CreateData.bHandleMouseButtonDown = false; //Default to NOT using the delegate. OnCreateWidgetForAction can set to true if we need it RowContent = OnCreateWidgetForAction.Execute( &CreateData ); } else { RowContent = SNew(SDefaultGraphActionWidget, &CreateData); } } else if( InItem->IsCategoryNode() ) { TWeakPtr< FGraphActionNode > WeakItem = InItem; // Hook up the delegate for verifying the category action is read only or not SGraphActionCategoryWidget::FArguments ReadOnlyArgument; if(bIsReadOnly) { ReadOnlyArgument.IsReadOnly(bIsReadOnly); } else { ReadOnlyArgument.IsReadOnly(this, &SGraphActionMenu::CanRenameNode, WeakItem); } TSharedRef CategoryWidget = SNew(SGraphActionCategoryWidget, InItem) .HighlightText(this, &SGraphActionMenu::GetFilterText) .OnTextCommitted(this, &SGraphActionMenu::OnNameTextCommitted, TWeakPtr< FGraphActionNode >(InItem)) .IsSelected(TableRow.Get(), &STableRow< TSharedPtr >::IsSelectedExclusively) .IsReadOnly(ReadOnlyArgument._IsReadOnly); if(!bIsReadOnly) { InItem->OnRenameRequest().BindSP( CategoryWidget->InlineWidget.Pin().Get(), &SInlineEditableTextBlock::EnterEditingMode ); } RowContent = CategoryWidget; } else if( InItem->IsSeparator() ) { RowPadding = FMargin(0); FText SectionTitle; if( OnGetSectionTitle.IsBound() ) { SectionTitle = OnGetSectionTitle.Execute(InItem->SectionID); } if( SectionTitle.IsEmpty() ) { RowContent = SNew( SVerticalBox ) .Visibility(EVisibility::HitTestInvisible) + SVerticalBox::Slot() .AutoHeight() // Add some empty space before the line, and a tiny bit after it .Padding( 0.0f, 5.f, 0.0f, 5.f ) [ SNew( SBorder ) // We'll use the border's padding to actually create the horizontal line .Padding(FEditorStyle::GetMargin(TEXT("Menu.Separator.Padding"))) // Separator graphic .BorderImage( FEditorStyle::GetBrush( TEXT( "Menu.Separator" ) ) ) ]; } else { RowContent = SNew(SHorizontalBox) + SHorizontalBox::Slot() .VAlign(VAlign_Center) [ SNew(SRichTextBlock) .Text(SectionTitle) .DecoratorStyleSet(&FEditorStyle::Get()) .TextStyle(FEditorStyle::Get(), "DetailsView.CategoryTextStyle") ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .HAlign(HAlign_Right) .Padding(FMargin(0,0,2,0)) [ OnGetSectionWidget.IsBound() ? OnGetSectionWidget.Execute(TableRow.ToSharedRef(), InItem->SectionID) : SNullWidget::NullWidget ]; } } TSharedPtr ExpanderWidget; if (OnCreateCustomRowExpander.IsBound()) { FCustomExpanderData CreateData; CreateData.TableRow = TableRow; CreateData.WidgetContainer = RowContainer; if (InItem->IsActionNode()) { check(InItem->HasValidAction()); CreateData.RowAction = InItem->GetPrimaryAction(); } ExpanderWidget = OnCreateCustomRowExpander.Execute(CreateData); } else { ExpanderWidget = SNew(SExpanderArrow, TableRow) .BaseIndentLevel(1); } RowContainer->AddSlot() .AutoWidth() .VAlign(VAlign_Fill) .HAlign(HAlign_Right) [ ExpanderWidget.ToSharedRef() ]; RowContainer->AddSlot() .FillWidth(1.0) .Padding(RowPadding) [ RowContent.ToSharedRef() ]; return TableRow.ToSharedRef(); } FText SGraphActionMenu::GetFilterText() const { // If there is an external source for the filter, use that text instead if(OnGetFilterText.IsBound()) { return OnGetFilterText.Execute(); } return FilterTextBox->GetText();; } void SGraphActionMenu::OnItemSelected( TSharedPtr< FGraphActionNode > InSelectedItem, ESelectInfo::Type SelectInfo ) { if (!bIgnoreUIUpdate) { HandleSelection(InSelectedItem, SelectInfo); } } void SGraphActionMenu::OnItemDoubleClicked( TSharedPtr< FGraphActionNode > InClickedItem ) { if ( InClickedItem.IsValid() && !bIgnoreUIUpdate ) { if ( InClickedItem->IsActionNode() ) { OnActionDoubleClicked.ExecuteIfBound(InClickedItem->Actions); } else if (InClickedItem->Children.Num()) { TreeView->SetItemExpansion(InClickedItem, !TreeView->IsItemExpanded(InClickedItem)); } } } FReply SGraphActionMenu::OnItemDragDetected( const FGeometry& MyGeometry, const FPointerEvent& MouseEvent ) { // Start a function-call drag event for any entry that can be called by kismet if (MouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton)) { TArray< TSharedPtr > SelectedNodes = TreeView->GetSelectedItems(); if(SelectedNodes.Num() > 0) { TSharedPtr Node = SelectedNodes[0]; // Dragging a ctaegory if(Node.IsValid() && Node->IsCategoryNode()) { if(OnCategoryDragged.IsBound()) { return OnCategoryDragged.Execute(Node->GetCategoryPath(), MouseEvent); } } // Dragging an action else { if(OnActionDragged.IsBound()) { TArray< TSharedPtr > Actions; GetSelectedActions(Actions); return OnActionDragged.Execute(Actions, MouseEvent); } } } } return FReply::Unhandled(); } bool SGraphActionMenu::OnMouseButtonDownEvent( TWeakPtr InAction ) { bool bResult = false; if( (!bIgnoreUIUpdate) && InAction.IsValid() ) { TArray< TSharedPtr > SelectionList = TreeView->GetSelectedItems(); TSharedPtr SelectedNode; if (SelectionList.Num() == 1) { SelectedNode = SelectionList[0]; } else if (FilteredActionNodes.Num() == 1) { SelectedNode = FilteredActionNodes[0]; } if (SelectedNode.IsValid() && SelectedNode->HasValidAction()) { if( SelectedNode->GetPrimaryAction().Get() == InAction.Pin().Get() ) { bResult = HandleSelection( SelectedNode, ESelectInfo::OnMouseClick ); } } } return bResult; } FReply SGraphActionMenu::OnKeyDown( const FGeometry& MyGeometry, const FKeyEvent& KeyEvent ) { int32 SelectionDelta = 0; // Escape dismisses the menu without placing a node if (KeyEvent.GetKey() == EKeys::Escape) { FSlateApplication::Get().DismissAllMenus(); return FReply::Handled(); } else if ((KeyEvent.GetKey() == EKeys::Enter) && !bIgnoreUIUpdate) { return TryToSpawnActiveSuggestion() ? FReply::Handled() : FReply::Unhandled(); } else if (!FilterTextBox->GetText().IsEmpty()) { // Needs to be done here in order not to eat up the text navigation key events when list isn't populated if (FilteredActionNodes.Num() == 0) { return FReply::Unhandled(); } if (KeyEvent.GetKey() == EKeys::Up) { SelectedSuggestion = FMath::Max(0, SelectedSuggestion - 1); } else if (KeyEvent.GetKey() == EKeys::Down) { SelectedSuggestion = FMath::Min(FilteredActionNodes.Num() - 1, SelectedSuggestion + 1); } else if (KeyEvent.GetKey() == EKeys::PageUp) { const int32 NumItemsInAPage = 15; // arbitrary jump because we can't get at the visible item count from here SelectedSuggestion = FMath::Max(0, SelectedSuggestion - NumItemsInAPage); } else if (KeyEvent.GetKey() == EKeys::PageDown) { const int32 NumItemsInAPage = 15; // arbitrary jump because we can't get at the visible item count from here SelectedSuggestion = FMath::Min(FilteredActionNodes.Num() - 1, SelectedSuggestion + NumItemsInAPage); } else if (KeyEvent.GetKey() == EKeys::Home && KeyEvent.IsControlDown()) { SelectedSuggestion = 0; } else if (KeyEvent.GetKey() == EKeys::End && KeyEvent.IsControlDown()) { SelectedSuggestion = FilteredActionNodes.Num() - 1; } else { return FReply::Unhandled(); } MarkActiveSuggestion(); return FReply::Handled(); } else { // When all else fails, it means we haven't filtered the list and we want to handle it as if we were just scrolling through a normal tree view return TreeView->OnKeyDown(FindChildGeometry(MyGeometry, TreeView.ToSharedRef()), KeyEvent); } return FReply::Unhandled(); } void SGraphActionMenu::MarkActiveSuggestion() { TGuardValue PreventSelectionFromTriggeringCommit(bIgnoreUIUpdate, true); if (SelectedSuggestion >= 0) { TSharedPtr& ActionToSelect = FilteredActionNodes[SelectedSuggestion]; TreeView->SetSelection(ActionToSelect); TreeView->RequestScrollIntoView(ActionToSelect); } else { TreeView->ClearSelection(); } } void SGraphActionMenu::AddReferencedObjects( FReferenceCollector& Collector ) { for (int32 CurTypeIndex=0; CurTypeIndex < AllActions.GetNumActions(); ++CurTypeIndex) { FGraphActionListBuilderBase::ActionGroup& Action = AllActions.GetAction( CurTypeIndex ); for ( int32 ActionIndex = 0; ActionIndex < Action.Actions.Num(); ActionIndex++ ) { Action.Actions[ActionIndex]->AddReferencedObjects(Collector); } } } FString SGraphActionMenu::GetReferencerName() const { return TEXT("SGraphActionMenu"); } bool SGraphActionMenu::HandleSelection( TSharedPtr< FGraphActionNode > &InSelectedItem, ESelectInfo::Type InSelectionType ) { bool bResult = false; if( OnActionSelected.IsBound() ) { if ( InSelectedItem.IsValid() && InSelectedItem->IsActionNode() ) { OnActionSelected.Execute(InSelectedItem->Actions, InSelectionType); bResult = true; } else { OnActionSelected.Execute(TArray< TSharedPtr >(), InSelectionType); bResult = true; } } return bResult; } void SGraphActionMenu::OnSetExpansionRecursive(TSharedPtr InTreeNode, bool bInIsItemExpanded) { if (InTreeNode.IsValid() && InTreeNode->Children.Num()) { TreeView->SetItemExpansion(InTreeNode, bInIsItemExpanded); for (TSharedPtr Child : InTreeNode->Children) { OnSetExpansionRecursive(Child, bInIsItemExpanded); } } } ///////////////////////////////////////////////////// #undef LOCTEXT_NAMESPACE