// Copyright Epic Games, Inc. All Rights Reserved. #include "SBlueprintNamespaceEntry.h" #include "Algo/Sort.h" #include "BlueprintNamespacePathTree.h" #include "BlueprintNamespaceRegistry.h" #include "Containers/StringFwd.h" #include "Framework/Application/SlateApplication.h" #include "Framework/Views/ITypedTableView.h" #include "Internationalization/Internationalization.h" #include "Layout/Children.h" #include "Layout/Margin.h" #include "Layout/Visibility.h" #include "Misc/AssertionMacros.h" #include "Misc/CString.h" #include "Misc/Char.h" #include "Misc/StringBuilder.h" #include "Misc/TextFilterExpressionEvaluator.h" #include "SlotBase.h" #include "Styling/SlateColor.h" #include "Types/SlateStructs.h" #include "UObject/NameTypes.h" #include "Widgets/Input/SComboButton.h" #include "Widgets/Input/SSearchBox.h" #include "Widgets/Input/SSuggestionTextBox.h" #include "Widgets/Layout/SBorder.h" #include "Widgets/Layout/SBox.h" #include "Widgets/Layout/SSeparator.h" #include "Widgets/SBoxPanel.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/Views/STableRow.h" class ITableRow; class SWidget; #define LOCTEXT_NAMESPACE "SBlueprintNamespaceEntry" float SBlueprintNamespaceEntry::NamespaceListBorderPadding = 1.0f; float SBlueprintNamespaceEntry::NamespaceListMinDesiredWidth = 350.0f; void SBlueprintNamespaceEntry::Construct(const FArguments& InArgs) { CurrentNamespace = InArgs._CurrentNamespace; OnNamespaceSelected = InArgs._OnNamespaceSelected; OnGetNamespacesToExclude = InArgs._OnGetNamespacesToExclude; ExcludedNamespaceTooltipText = InArgs._ExcludedNamespaceTooltipText; ChildSlot [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .FillWidth(1.0f) [ SAssignNew(TextBox, SSuggestionTextBox) .Font(InArgs._Font) .ForegroundColor(FSlateColor::UseForeground()) .Visibility(InArgs._AllowTextEntry ? EVisibility::Visible : EVisibility::Collapsed) .Text(this, &SBlueprintNamespaceEntry::GetCurrentNamespaceText) .OnTextChanged(this, &SBlueprintNamespaceEntry::OnTextChanged) .OnTextCommitted(this, &SBlueprintNamespaceEntry::OnTextCommitted) .OnShowingSuggestions(this, &SBlueprintNamespaceEntry::OnShowingSuggestions) ] +SHorizontalBox::Slot() .AutoWidth() [ SAssignNew(ComboButton, SComboButton) .CollapseMenuOnParentFocus(true) .OnGetMenuContent(this, &SBlueprintNamespaceEntry::OnGetNamespaceTreeMenuContent) .ButtonContent() [ InArgs._ButtonContent.Widget ] ] ]; } void SBlueprintNamespaceEntry::SetCurrentNamespace(const FString& InNamespace) { // Pass through the text box in order to validate the string before committing it to the current value. if (TextBox.IsValid()) { TextBox->SetText(FText::FromString(InNamespace)); } } void SBlueprintNamespaceEntry::OnTextChanged(const FText& InText) { // Note: Empty string is valid (i.e. global namespace). bool bIsValidString = true; // Only allow alphanumeric characters, '.' and '_'. FString NewString = InText.ToString(); for (const TCHAR& NewChar : NewString) { if (!FChar::IsAlnum(NewChar) && NewChar != TEXT('_') && NewChar != TEXT('.')) { bIsValidString = false; break; } } FString ErrorText; if (bIsValidString) { // Keep the current namespace in sync with the last-known valid text box value. CurrentNamespace = MoveTemp(NewString); } else { ErrorText = LOCTEXT("InvalidNamespaceIdentifierStringError", "Invalid namespace identifier string.").ToString(); } // Set the error text regardless of whether or not the path is valid; this will clear the error state if the string is valid. if (TextBox.IsValid()) { TextBox->SetError(ErrorText); } } void SBlueprintNamespaceEntry::OnTextCommitted(const FText& NewText, ETextCommit::Type InTextCommit) { // Ensure that case correctly matches up with any registered path name(s). FString CaseCorrectedPath; TArray PathSegments; CurrentNamespace.ParseIntoArray(PathSegments, FBlueprintNamespacePathTree::PathSeparator); for (FString& PathSegment : PathSegments) { TArray ActualNames; FBlueprintNamespaceRegistry::Get().GetNamesUnderPath(CaseCorrectedPath, ActualNames); for (const FName& ActualName : ActualNames) { FString ActualNameAsString = ActualName.ToString(); if (PathSegment.Equals(ActualNameAsString, ESearchCase::IgnoreCase)) { PathSegment = MoveTemp(ActualNameAsString); break; } } if (!CaseCorrectedPath.IsEmpty()) { CaseCorrectedPath += FBlueprintNamespacePathTree::PathSeparator; } CaseCorrectedPath += PathSegment; } // Update the current namespace string. CurrentNamespace = MoveTemp(CaseCorrectedPath); // Not using the current textbox value here because it might be invalid, and we want to revert to the last-known valid namespace string on commit. SelectNamespace(CurrentNamespace); } void SBlueprintNamespaceEntry::OnShowingSuggestions(const FString& InputText, TArray& OutSuggestions) { int32 PathEnd; FString CurrentPath; FString CurrentName; if (InputText.FindLastChar(FBlueprintNamespacePathTree::PathSeparator[0], PathEnd)) { CurrentPath = InputText.LeftChop(InputText.Len() - PathEnd); CurrentName = InputText.RightChop(PathEnd + 1); } else { CurrentName = InputText; } // Find all names (path segments) that fall under the current path prefix. TArray SuggestedNames; FBlueprintNamespaceRegistry::Get().GetNamesUnderPath(CurrentPath, SuggestedNames); // Sort the list alphabetically. Algo::Sort(SuggestedNames, FNameLexicalLess()); // Allow the owner to exclude one or more paths. TSet ExcludedPaths; OnGetNamespacesToExclude.ExecuteIfBound(ExcludedPaths); // Build the suggestion set based on the set of matching names we found above. TStringBuilder<128> PathBuilder; for (FName SuggestedName : SuggestedNames) { FString SuggestedNameAsString = SuggestedName.ToString(); if (CurrentName.IsEmpty() || SuggestedNameAsString.StartsWith(CurrentName)) { if (CurrentPath.Len() > 0) { PathBuilder += CurrentPath; PathBuilder += FBlueprintNamespacePathTree::PathSeparator; } PathBuilder += SuggestedNameAsString; FString SuggestedNamespace = PathBuilder.ToString(); if (!ExcludedPaths.Contains(SuggestedNamespace)) { OutSuggestions.Add(MoveTemp(SuggestedNamespace)); } PathBuilder.Reset(); } } } TSharedRef SBlueprintNamespaceEntry::OnGetNamespaceTreeMenuContent() { // Gather the full set of registered namespace paths. AllRegisteredPaths.Reset(); FBlueprintNamespaceRegistry::Get().GetAllRegisteredPaths(AllRegisteredPaths); // Allow external owners to filter the list. ExcludedTreeViewPaths.Reset(); OnGetNamespacesToExclude.ExecuteIfBound(ExcludedTreeViewPaths); if (!ExcludedTreeViewPaths.IsEmpty()) { AllRegisteredPaths.RemoveAllSwap([&ExcludedTreeViewPaths = this->ExcludedTreeViewPaths](const FString& Path) { return ExcludedTreeViewPaths.Contains(Path); }); } // Sort the list alphabetically. AllRegisteredPaths.Sort(); // Reset the search box so we don't reapply a previous menu's filter. SearchBox.Reset(); // Build the namespace item tree from the filtered list of registered paths. PopulateNamespaceTree(); // Construct the tree view widget that we'll use for the menu content. SAssignNew(TreeView, STreeView>) .SelectionMode(ESelectionMode::Single) .TreeItemsSource(&RootNodes) .OnGenerateRow(this, &SBlueprintNamespaceEntry::OnGenerateRowForNamespaceTreeItem) .OnGetChildren(this, &SBlueprintNamespaceEntry::OnGetChildrenForNamespaceTreeItem) .OnSelectionChanged(this, &SBlueprintNamespaceEntry::OnNamespaceTreeSelectionChanged) .OnIsSelectableOrNavigable(this, &SBlueprintNamespaceEntry::OnIsNamespaceTreeItemSelectable); // All tree view items are always expanded by default. ExpandAllTreeViewItems(); // If we are allowing manual entry and the current namespace is non-empty, look for a matching item in the set. if (!CurrentNamespace.IsEmpty() && TextBox.IsValid() && TextBox->GetVisibility().IsVisible()) { // If we found a match, make it the initial selection. if (const TSharedPtr* CurrentItemPtr = FindTreeViewNode(CurrentNamespace)) { TreeView->SetSelection(*CurrentItemPtr); } } return SNew(SBorder) .Padding(NamespaceListBorderPadding) [ SNew(SBox) .MinDesiredWidth(NamespaceListMinDesiredWidth) [ SNew(SVerticalBox) +SVerticalBox::Slot() .AutoHeight() [ SAssignNew(SearchBox, SSearchBox) .OnTextChanged(this, &SBlueprintNamespaceEntry::OnNamespaceTreeFilterTextChanged) ] +SVerticalBox::Slot() .AutoHeight() [ SNew(SSeparator) ] +SVerticalBox::Slot() .FillHeight(1.0f) [ TreeView.ToSharedRef() ] ] ]; } TSharedRef SBlueprintNamespaceEntry::OnGenerateRowForNamespaceTreeItem(TSharedPtr Item, const TSharedRef& OwnerTable) { check(Item.IsValid()); // Check for an empty tree and add a single (disabled) item if found. bool bIsEnabled = true; FText ItemText; if (Item->NodePath.IsEmpty() && RootNodes.Num() == 1 && RootNodes[0] == Item) { bIsEnabled = false; ItemText = LOCTEXT("BlueprintNamespaceList_NoItems", "No Matching Items"); } else { ItemText = FText::FromString(*Item->NodePath); } FText ToolTipText = FText::GetEmpty(); if (!Item->bIsSelectable && ExcludedNamespaceTooltipText.IsSet()) { ToolTipText = ExcludedNamespaceTooltipText.Get(); } // Construct a new row widget, highlighting any text that matches the search filter. return SNew(STableRow>, OwnerTable) .IsEnabled(bIsEnabled) .ShowSelection(Item->bIsSelectable) [ SNew(STextBlock) .Text(ItemText) .ToolTipText(ToolTipText) .IsEnabled(Item->bIsSelectable) .HighlightText(bIsEnabled && SearchBox.IsValid() ? SearchBox->GetText() : FText::GetEmpty()) ]; } void SBlueprintNamespaceEntry::OnGetChildrenForNamespaceTreeItem(TSharedPtr Item, TArray>& OutChildren) { check(Item.IsValid()); OutChildren.Append(Item->ChildNodes); } void SBlueprintNamespaceEntry::OnNamespaceTreeFilterTextChanged(const FText& InText) { // Gather/filter all registered paths. PopulateNamespaceTree(); // Refresh the namespace item tree view. if (TreeView.IsValid()) { ExpandAllTreeViewItems(); TreeView->RequestTreeRefresh(); } } void SBlueprintNamespaceEntry::OnNamespaceTreeSelectionChanged(TSharedPtr Item, ESelectInfo::Type SelectInfo) { // These actions should not trigger a selection. if (SelectInfo == ESelectInfo::OnNavigation || SelectInfo == ESelectInfo::Direct) { return; } if (Item.IsValid()) { // Disallow selection of inclusive nodes that are filtered out. if (!Item->bIsSelectable) { return; } SelectNamespace(*Item->NodePath); } // Clear the search filter text. if (SearchBox.IsValid()) { SearchBox->SetText(FText::GetEmpty()); } // Close the combo button menu after a selection. if (ComboButton.IsValid()) { ComboButton->SetIsOpen(false); } // Switch focus back to the text box if present and visible. if (TextBox.IsValid() && TextBox->GetVisibility() == EVisibility::Visible) { FSlateApplication::Get().SetKeyboardFocus(TextBox); FSlateApplication::Get().SetUserFocus(0, TextBox); } } bool SBlueprintNamespaceEntry::OnIsNamespaceTreeItemSelectable(TSharedPtr Item) const { return Item.IsValid() && Item->bIsSelectable; } FText SBlueprintNamespaceEntry::GetCurrentNamespaceText() const { return FText::FromString(CurrentNamespace); } void SBlueprintNamespaceEntry::PopulateNamespaceTree() { // Clear the current list. RootNodes.Reset(); // Set up an expression evaluator to further trim the list according to the search filter. FTextFilterExpressionEvaluator SearchFilter(ETextFilterExpressionEvaluatorMode::BasicString); SearchFilter.SetFilterText(SearchBox.IsValid() ? SearchBox->GetText() : FText::GetEmpty()); // Build the source for the tree view widget. for (const FString& Path : AllRegisteredPaths) { // Only include items that match the current search filter text. if (SearchFilter.TestTextFilter(FBasicStringFilterExpressionContext(Path))) { FString CurrentNodePath; TArray>* NodeList = &RootNodes; TArray PathSegments; Path.ParseIntoArray(PathSegments, FBlueprintNamespacePathTree::PathSeparator); for (const FString& PathSegment : PathSegments) { CurrentNodePath += PathSegment; TSharedPtr* NodePtr = NodeList->FindByPredicate([&CurrentNodePath](const TSharedPtr& Value) { return CurrentNodePath.Equals(Value->NodePath, ESearchCase::IgnoreCase); }); // Add a new node to the current list if an existing one was not found. if (!NodePtr) { NodePtr = &NodeList->Add_GetRef(MakeShared()); // Note: We're intentionally using the full path name here rather than just the segment name. (*NodePtr)->NodePath = CurrentNodePath; // Treat inclusive nodes that are filtered out by the owner as non-selectable. // // Example: Consider that "MyProject.MyNamespace" is a registered namespace. When building the tree view for the // drop-down menu, we will first include "MyProject" as the subtree root, and add "MyProject.MyNamespace" as a // child node. If the owner excludes "MyProject" as a namespace (e.g. because it's already imported as an inclusive // namespace), we could also exclude it from the drop-down, but this would mask the hierarchical relationship. So // rather than exclude it from the tree, we make it a non-selectable node. (*NodePtr)->bIsSelectable = !ExcludedTreeViewPaths.Contains(CurrentNodePath); } NodeList = &(*NodePtr)->ChildNodes; CurrentNodePath += FBlueprintNamespacePathTree::PathSeparator; } } } // If no items were added, we signal this by adding a single blank entry. if (RootNodes.Num() == 0) { RootNodes.Add(MakeShared()); } } void SBlueprintNamespaceEntry::SelectNamespace(const FString& InNamespace) { if (TextBox.IsValid()) { // Update the textbox to reflect the selected value. Note that this should also clear any error state via OnTextChanged(). TextBox->SetText(FText::FromString(InNamespace)); } // Invoke the delegate in response to the new selection. OnNamespaceSelected.ExecuteIfBound(InNamespace); } void SBlueprintNamespaceEntry::ExpandAllTreeViewItems(const TArray>* NodeListPtr) { if (!TreeView.IsValid()) { return; } if (NodeListPtr == nullptr) { NodeListPtr = &RootNodes; } for (const TSharedPtr& NodeItem : *NodeListPtr) { check(NodeItem.IsValid()); TreeView->SetItemExpansion(NodeItem, true); ExpandAllTreeViewItems(&NodeItem->ChildNodes); } } const TSharedPtr* SBlueprintNamespaceEntry::FindTreeViewNode(const FString& NodePath, const TArray>* NodeListPtr) const { const TSharedPtr* Result = nullptr; if (NodeListPtr == nullptr) { NodeListPtr = &RootNodes; } for (const TSharedPtr& NodeItem : *NodeListPtr) { check(NodeItem.IsValid()); if (NodeItem->NodePath.Equals(NodePath, ESearchCase::IgnoreCase)) { Result = &NodeItem; } else { Result = FindTreeViewNode(NodePath, &NodeItem->ChildNodes); } if (Result) { break; } } return Result; } PRAGMA_DISABLE_DEPRECATION_WARNINGS void SBlueprintNamespaceEntry::HandleLegacyOnFilterNamespaceList(TSet& OutNamespacesToExclude, FOnFilterNamespaceList LegacyDelegate) PRAGMA_ENABLE_DEPRECATION_WARNINGS { if (LegacyDelegate.IsBound()) { TArray SourceList; FBlueprintNamespaceRegistry::Get().GetAllRegisteredPaths(SourceList); TArray FilteredList = SourceList; LegacyDelegate.Execute(FilteredList); for (const FString& SourcePath : SourceList) { if (!FilteredList.Contains(SourcePath)) { OutNamespacesToExclude.Add(SourcePath); } } } } #undef LOCTEXT_NAMESPACE