// Copyright Epic Games, Inc. All Rights Reserved. #include "StringTableEditor.h" #include "Misc/FileHelper.h" #include "Modules/ModuleManager.h" #include "Styling/AppStyle.h" #include "EditorDirectories.h" #include "DesktopPlatformModule.h" #include "StringTableEditorModule.h" #include "Internationalization/StringTable.h" #include "Internationalization/StringTableCore.h" #include "Internationalization/TextNamespaceUtil.h" #include "Editor.h" #include "ScopedTransaction.h" #include "Framework/Application/SlateApplication.h" #include "Framework/Commands/UICommandList.h" #include "Framework/Commands/GenericCommands.h" #include "Framework/MultiBox/MultiBoxBuilder.h" #include "Widgets/Input/SButton.h" #include "Widgets/Images/SImage.h" #include "Widgets/Docking/SDockTab.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/Views/SHeaderRow.h" #include "Widgets/Input/SSearchBox.h" #include "Widgets/Text/SInlineEditableTextBlock.h" #define LOCTEXT_NAMESPACE "StringTableEditor" const FName FStringTableEditor::StringTableTabId("StringTableEditor_StringTable"); const FName FStringTableEditor::StringTableDummyColumnId("Dummy"); const FName FStringTableEditor::StringTableKeyColumnId("Key"); const FName FStringTableEditor::StringTableSourceStringColumnId("SourceString"); const FName FStringTableEditor::StringTableDeleteColumnId("Delete"); class SStringTableEntryRow : public SMultiColumnTableRow> { public: void Construct(const FTableRowArgs& InArgs, const TSharedRef& OwnerTableView, FStringTableEditor& InOwnerEditor, TSharedRef InCachedStringTableEntry) { OwnerEditor = &InOwnerEditor; CachedStringTableEntry = InCachedStringTableEntry; FSuperRowType::Construct(InArgs, OwnerTableView); } TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override { TSharedPtr Return; if (ColumnName == FStringTableEditor::StringTableDummyColumnId) { Return = SNew(SBorder) .BorderImage(&FCoreStyle::Get().GetWidgetStyle("TableView.Header").BackgroundBrush); } else if (ColumnName == FStringTableEditor::StringTableKeyColumnId) { Return = SNew(SBox) .Padding(4) [ SNew(SInlineEditableTextBlock) .IsReadOnly(true) .Text(FText::FromString(CachedStringTableEntry->Key)) ]; } else if (ColumnName == FStringTableEditor::StringTableSourceStringColumnId) { Return = SNew(SBorder) .BorderImage(&FCoreStyle::Get().GetWidgetStyle("NormalEditableTextBox").BackgroundImageReadOnly) [ SNew(SMultiLineEditableTextBox) .Text(FText::FromString(CachedStringTableEntry->SourceString)) .ModiferKeyForNewLine(EModifierKey::Shift) .OnTextCommitted(this, &SStringTableEntryRow::OnSourceStringCommitted) ]; } else if (ColumnName == FStringTableEditor::StringTableDeleteColumnId) { Return = SNew(SButton) .ButtonStyle(FAppStyle::Get(), "HoverHintOnly") .ForegroundColor(FSlateColor::UseForeground()) .HAlign(HAlign_Center) .VAlign(VAlign_Center) .OnClicked(this, &SStringTableEntryRow::OnDeleteEntryClicked) .ToolTipText(LOCTEXT("DeleteEntryTooltip", "Delete this entry from the string table")) [ SNew(SImage) .Image(FAppStyle::Get().GetBrush("Cross")) ]; } return Return.IsValid() ? Return.ToSharedRef() : SNullWidget::NullWidget; } private: void OnSourceStringCommitted(const FText& InText, ETextCommit::Type) { const FString NewSourceString = InText.ToString(); if (!NewSourceString.IsEmpty() && !CachedStringTableEntry->SourceString.Equals(NewSourceString, ESearchCase::CaseSensitive)) { CachedStringTableEntry->SourceString = NewSourceString; OwnerEditor->SetEntry(CachedStringTableEntry->Key, CachedStringTableEntry->SourceString); } } FReply OnDeleteEntryClicked() { OwnerEditor->DeleteEntry(CachedStringTableEntry->Key); return FReply::Handled(); } FStringTableEditor* OwnerEditor; TSharedPtr CachedStringTableEntry; }; void FStringTableEditor::RegisterTabSpawners(const TSharedRef& InTabManager) { WorkspaceMenuCategory = InTabManager->AddLocalWorkspaceMenuCategory(LOCTEXT("WorkspaceMenu_StringTableEditor", "String Table Editor")); FAssetEditorToolkit::RegisterTabSpawners(InTabManager); InTabManager->RegisterTabSpawner(StringTableTabId, FOnSpawnTab::CreateSP(this, &FStringTableEditor::SpawnTab_StringTable) ) .SetDisplayName(LOCTEXT("StringTableTab", "String Table")) .SetGroup(WorkspaceMenuCategory.ToSharedRef()); } void FStringTableEditor::UnregisterTabSpawners(const TSharedRef& InTabManager) { FAssetEditorToolkit::UnregisterTabSpawners(InTabManager); InTabManager->UnregisterTabSpawner(StringTableTabId); } FStringTableEditor::~FStringTableEditor() { GEditor->UnregisterForUndo(this); } void FStringTableEditor::PostUndo(bool bSuccess) { HandleUndoRedo(); } void FStringTableEditor::PostRedo(bool bSuccess) { HandleUndoRedo(); } void FStringTableEditor::HandleUndoRedo() { const UStringTable* StringTable = GetStringTable(); if (StringTable) { HandlePostChange(); } } const UStringTable* FStringTableEditor::GetStringTable() const { return Cast(GetEditingObject()); } void FStringTableEditor::RefreshStringTableEditor(const FString& NewSelection) { HandlePostChange(NewSelection); } void FStringTableEditor::HandlePostChange(const FString& NewSelection) { // We need to cache and restore the selection here as RefreshCachedStringTable will re-create the list view items FString CachedSelection = NewSelection; if (CachedSelection.IsEmpty()) { TArray> SelectedEntries; StringTableEntriesListView->GetSelectedItems(SelectedEntries); if (SelectedEntries.Num() == 1) { CachedSelection = SelectedEntries[0]->Key; } } RefreshCachedStringTable(CachedSelection); } void FStringTableEditor::InitStringTableEditor(const EToolkitMode::Type Mode, const TSharedPtr& InitToolkitHost, UStringTable* StringTable) { TSharedRef StandaloneDefaultLayout = FTabManager::NewLayout("Standalone_StringTableEditor_Layout_v2") ->AddArea ( FTabManager::NewPrimaryArea()->SetOrientation(Orient_Vertical) ->Split ( FTabManager::NewStack() ->SetHideTabWell(true) ->AddTab(StringTableTabId, ETabState::OpenedTab) ) ); const bool bCreateDefaultStandaloneMenu = true; const bool bCreateDefaultToolbar = true; FAssetEditorToolkit::InitAssetEditor(Mode, InitToolkitHost, FStringTableEditorModule::StringTableEditorAppIdentifier, StandaloneDefaultLayout, bCreateDefaultStandaloneMenu, bCreateDefaultToolbar, StringTable); FStringTableEditorModule& StringTableEditorModule = FModuleManager::LoadModuleChecked("StringTableEditor"); AddMenuExtender(StringTableEditorModule.GetMenuExtensibilityManager()->GetAllExtenders(GetToolkitCommands(), GetEditingObjects())); AddToolbarExtender(StringTableEditorModule.GetToolBarExtensibilityManager()->GetAllExtenders(GetToolkitCommands(), GetEditingObjects())); RegenerateMenusAndToolbars(); // Support undo/redo GEditor->RegisterForUndo(this); } FName FStringTableEditor::GetToolkitFName() const { return FName("StringTableEditor"); } FText FStringTableEditor::GetBaseToolkitName() const { return LOCTEXT("AppLabel", "String Table Editor"); } FString FStringTableEditor::GetWorldCentricTabPrefix() const { return LOCTEXT("WorldCentricTabPrefix", "StringTable ").ToString(); } FLinearColor FStringTableEditor::GetWorldCentricTabColorScale() const { return FLinearColor(0.0f, 0.0f, 0.2f, 0.5f); } TSharedRef FStringTableEditor::SpawnTab_StringTable(const FSpawnTabArgs& Args) { check(Args.GetTabId().TabType == StringTableTabId); const FGenericCommands& GenericCommands = FGenericCommands::Get(); CommandList = MakeShareable(new FUICommandList); CommandList->MapAction( GenericCommands.Delete, FExecuteAction::CreateSP(this, &FStringTableEditor::ExecuteDelete), FCanExecuteAction::CreateSP(this, &FStringTableEditor::CanExecuteDelete)); UStringTable* StringTable = Cast(GetEditingObject()); // Support undo/redo if (StringTable) { StringTable->SetFlags(RF_Transactional); } EntryTextFilter = MakeShareable(new FEntryTextFilter(FEntryTextFilter::FItemToStringArray::CreateLambda([](const TSharedPtr& InItem, OUT TArray< FString >& StringArray) { StringArray.Add(InItem->Key); StringArray.Add(InItem->SourceString); }))); TSharedRef StringTableTab = SNew(SDockTab) .Label(LOCTEXT("StringTableTitle", "String Table")) .TabColorScale(GetTabColorScale()) [ SNew(SVerticalBox) +SVerticalBox::Slot() .AutoHeight() [ SNew(SBorder) .Padding(2) .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(STextBlock) .Text(LOCTEXT("NamespaceLabel", "Namespace:")) ] +SHorizontalBox::Slot() .Padding(2) .VAlign(VAlign_Center) [ SAssignNew(NamespaceEditableTextBox, SEditableTextBox) .Text(this, &FStringTableEditor::GetNamespace) .OnTextChanged(this, &FStringTableEditor::OnNamespaceChanged) .OnTextCommitted(this, &FStringTableEditor::OnNamespaceCommitted) ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(SButton) .Text(LOCTEXT("ImportFromCSVLabel", "Import from CSV")) .OnClicked(this, &FStringTableEditor::OnImportFromCSVClicked) .ButtonStyle(FAppStyle::Get(), "FlatButton.Default") .ForegroundColor(FLinearColor::White) .ContentPadding(FMargin(6, 2)) ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(SButton) .Text(LOCTEXT("ExportToCSVLabel", "Export to CSV")) .OnClicked(this, &FStringTableEditor::OnExportToCSVClicked) .ButtonStyle(FAppStyle::Get(), "FlatButton.Default") .ForegroundColor(FLinearColor::White) .ContentPadding(FMargin(6, 2)) ] ] ] + SVerticalBox::Slot() .AutoHeight() .Padding(0, 4, 0, 4) [ SNew(SBorder) .Padding(2) .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) [ SAssignNew(SearchBox, SSearchBox) .OnTextChanged(this, &FStringTableEditor::OnFilterTextChanged) ] ] +SVerticalBox::Slot() [ SNew(SBorder) .Padding(2) .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) [ SAssignNew(StringTableEntriesListView, SListView>) .ListItemsSource(&CachedStringTableEntries) .OnGenerateRow(this, &FStringTableEditor::OnGenerateStringTableEntryRow) .SelectionMode(ESelectionMode::Multi) .OnContextMenuOpening(this, &FStringTableEditor::OnConstructContextMenu) .HeaderRow ( SNew(SHeaderRow) +SHeaderRow::Column(StringTableDummyColumnId) .DefaultLabel(FText::GetEmpty()) .FixedWidth(20) +SHeaderRow::Column(StringTableKeyColumnId) .DefaultLabel(LOCTEXT("KeyColumnLabel", "Key")) .FillWidth(0.2f) +SHeaderRow::Column(StringTableSourceStringColumnId) .DefaultLabel(LOCTEXT("SourceStringColumnLabel", "Source String")) .FillWidth(1.0f) +SHeaderRow::Column(StringTableDeleteColumnId) .DefaultLabel(FText::GetEmpty()) .FixedWidth(28) ) ] ] +SVerticalBox::Slot() .Padding(0, 4, 0, 0) .AutoHeight() [ SNew(SBorder) .Padding(2) .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(STextBlock) .Text(LOCTEXT("KeyLabel", "Key:")) ] +SHorizontalBox::Slot() .FillWidth(0.2f) .Padding(2) .VAlign(VAlign_Center) [ SAssignNew(KeyEditableTextBox, SEditableTextBox) .OnTextChanged(this, &FStringTableEditor::OnKeyChanged) ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(STextBlock) .Text(LOCTEXT("SourceStringLabel", "Source String:")) ] +SHorizontalBox::Slot() .Padding(2) .VAlign(VAlign_Center) [ SAssignNew(SourceStringEditableTextBox, SMultiLineEditableTextBox) .ModiferKeyForNewLine(EModifierKey::Shift) .OnTextCommitted(this, &FStringTableEditor::OnSourceStringCommitted) ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2) .VAlign(VAlign_Center) [ SNew(SButton) .Text(LOCTEXT("AddLabel", "Add")) .OnClicked(this, &FStringTableEditor::OnAddClicked) .ButtonStyle(FAppStyle::Get(), "FlatButton.Success") .ForegroundColor(FLinearColor::White) .ContentPadding(FMargin(6, 2)) ] ] ] ]; RefreshCachedStringTable(); return StringTableTab; } TSharedPtr FStringTableEditor::OnConstructContextMenu() { const bool bShouldCloseWindowAfterMenuSelection = true; FMenuBuilder MenuBuilder(bShouldCloseWindowAfterMenuSelection, CommandList); MenuBuilder.BeginSection("EditOperations", LOCTEXT("StringTableEditOperationsLabel", "Edit")); { MenuBuilder.AddMenuEntry(FGenericCommands::Get().Delete); } MenuBuilder.EndSection(); return MenuBuilder.MakeWidget(); } TSharedRef FStringTableEditor::OnGenerateStringTableEntryRow(TSharedPtr CachedStringTableEntry, const TSharedRef& Table) { return SNew(SStringTableEntryRow, Table, *this, CachedStringTableEntry.ToSharedRef()); } void FStringTableEditor::RefreshCachedStringTable(const FString& InCachedSelection) { CachedStringTableEntries.Reset(); TSharedPtr SelectedStringTableEntry; UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { StringTable->GetStringTable()->EnumerateSourceStrings([&](const FString& InKey, const FString& InSourceString) { TSharedRef NewStringTableEntry = MakeShared(InKey, InSourceString); if (EntryTextFilter->PassesFilter(NewStringTableEntry)) { CachedStringTableEntries.Add(NewStringTableEntry); } if (InCachedSelection.Equals(InKey, ESearchCase::CaseSensitive)) { SelectedStringTableEntry = NewStringTableEntry; } return true; // continue enumeration }); } CachedStringTableEntries.Sort([](const TSharedPtr& InEntryOne, const TSharedPtr& InEntryTwo) { return InEntryOne->Key < InEntryTwo->Key; }); StringTableEntriesListView->RequestListRefresh(); if (!SelectedStringTableEntry.IsValid() && CachedStringTableEntries.Num() > 0) { SelectedStringTableEntry = CachedStringTableEntries[0]; } if (SelectedStringTableEntry.IsValid()) { StringTableEntriesListView->SetSelection(SelectedStringTableEntry); StringTableEntriesListView->RequestScrollIntoView(SelectedStringTableEntry); } else { StringTableEntriesListView->ClearSelection(); } } FText FStringTableEditor::GetNamespace() const { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { return FText::FromString(StringTable->GetStringTable()->GetNamespace()); } return FText::GetEmpty(); } void FStringTableEditor::OnNamespaceChanged(const FText& InText) { FText ErrorText; const FText ErrorCtx = LOCTEXT("TextNamespaceErrorCtx", "Namespace"); IsValidIdentity(InText, &ErrorText, &ErrorCtx); NamespaceEditableTextBox->SetError(ErrorText); } void FStringTableEditor::OnNamespaceCommitted(const FText& InText, ETextCommit::Type) { if (IsValidIdentity(InText)) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { const FString NewNamespace = InText.ToString(); if (!StringTable->GetStringTable()->GetNamespace().Equals(NewNamespace, ESearchCase::CaseSensitive)) { const FScopedTransaction Transaction(LOCTEXT("SetNamespace", "Set Namespace")); StringTable->Modify(); StringTable->GetMutableStringTable()->SetNamespace(NewNamespace); HandlePostChange(); } } } } void FStringTableEditor::SetEntry(const FString& InKey, const FString& InSourceString) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { const FScopedTransaction Transaction(LOCTEXT("SetStringTableEntry", "Set String Table Entry")); StringTable->Modify(); StringTable->GetMutableStringTable()->SetSourceString(InKey, InSourceString); HandlePostChange(InKey); } } void FStringTableEditor::DeleteEntry(const FString& InKey) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { const FScopedTransaction Transaction(LOCTEXT("DeleteStringTableEntry", "Delete String Table Entry")); StringTable->Modify(); StringTable->GetMutableStringTable()->RemoveSourceString(InKey); HandlePostChange(); } } bool FStringTableEditor::IsValidIdentity(const FText& InIdentity, FText* OutReason, const FText* InErrorCtx) const { const FString InvalidIdentityChars = FString::Printf(TEXT("%s%c%c"), INVALID_NAME_CHARACTERS, TextNamespaceUtil::PackageNamespaceStartMarker, TextNamespaceUtil::PackageNamespaceEndMarker); return FName::IsValidXName(InIdentity.ToString(), InvalidIdentityChars, OutReason, InErrorCtx); } void FStringTableEditor::OnKeyChanged(const FText& InText) { FText ErrorText; const FText ErrorCtx = LOCTEXT("TextKeyErrorCtx", "Key"); const bool bIsValidName = IsValidIdentity(InText, &ErrorText, &ErrorCtx); if (InText.IsEmptyOrWhitespace()) { ErrorText = LOCTEXT("Error_EmptyKey", "Key cannot be empty."); } else if (bIsValidName) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { const FString NewKey = KeyEditableTextBox->GetText().ToString(); if (StringTable->GetStringTable()->FindEntry(NewKey).IsValid()) { ErrorText = LOCTEXT("Warning_DuplicateKey", "This key is already being used by this string table. Adding this entry will replace the existing entry."); } } } KeyEditableTextBox->SetError(ErrorText); } void FStringTableEditor::OnSourceStringCommitted(const FText& InText, ETextCommit::Type InCommitType) { if (InCommitType == ETextCommit::OnEnter) { OnAddClicked(); } } void FStringTableEditor::OnFilterTextChanged(const FText& InNewText) { EntryTextFilter->SetRawFilterText(InNewText); SearchBox->SetError(EntryTextFilter->GetFilterErrorText()); RefreshCachedStringTable(); StringTableEntriesListView->RebuildList(); } bool FStringTableEditor::CanExecuteDelete() const { return StringTableEntriesListView->GetNumItemsSelected() > 0; } void FStringTableEditor::ExecuteDelete() { const TArray>& Items = StringTableEntriesListView->GetSelectedItems(); for (const auto& Item : Items) { DeleteEntry(Item->Key); } } FReply FStringTableEditor::OnAddClicked() { const FText NewKey = KeyEditableTextBox->GetText(); const FText NewSourceString = SourceStringEditableTextBox->GetText(); if (!NewKey.IsEmptyOrWhitespace() && IsValidIdentity(NewKey) && !NewSourceString.IsEmpty()) { SetEntry(NewKey.ToString(), NewSourceString.ToString()); KeyEditableTextBox->SetText(FText::GetEmpty()); SourceStringEditableTextBox->SetText(FText::GetEmpty()); KeyEditableTextBox->SetError(FText::GetEmpty()); } return FReply::Handled(); } FReply FStringTableEditor::OnImportFromCSVClicked() { IDesktopPlatform* const DesktopPlatform = FDesktopPlatformModule::Get(); if (DesktopPlatform) { const FString DefaultPath = FEditorDirectories::Get().GetLastDirectory(ELastDirectory::GENERIC_IMPORT); TSharedPtr ParentWindow = FSlateApplication::Get().FindWidgetWindow(StringTableEntriesListView.ToSharedRef()); const void* const ParentWindowHandle = (ParentWindow.IsValid() && ParentWindow->GetNativeWindow().IsValid()) ? ParentWindow->GetNativeWindow()->GetOSWindowHandle() : nullptr; TArray OutFiles; if (DesktopPlatform->OpenFileDialog( ParentWindowHandle, LOCTEXT("ImportStringTableTitle", "Choose a string table CSV file...").ToString(), DefaultPath, TEXT(""), TEXT("String Table CSV (*.csv)|*.csv"), EFileDialogFlags::None, OutFiles )) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { const FScopedTransaction Transaction(LOCTEXT("ImportStringTableEntries", "Import String Table Entries")); StringTable->Modify(); StringTable->GetMutableStringTable()->ImportStrings(OutFiles[0]); HandlePostChange(); } } } return FReply::Handled(); } FReply FStringTableEditor::OnExportToCSVClicked() { IDesktopPlatform* const DesktopPlatform = FDesktopPlatformModule::Get(); if (DesktopPlatform) { const FString DefaultPath = FEditorDirectories::Get().GetLastDirectory(ELastDirectory::GENERIC_EXPORT); TSharedPtr ParentWindow = FSlateApplication::Get().FindWidgetWindow(StringTableEntriesListView.ToSharedRef()); const void* const ParentWindowHandle = (ParentWindow.IsValid() && ParentWindow->GetNativeWindow().IsValid()) ? ParentWindow->GetNativeWindow()->GetOSWindowHandle() : nullptr; TArray OutFiles; if (DesktopPlatform->SaveFileDialog( ParentWindowHandle, LOCTEXT("ExportStringTableTitle", "Choose a string table CSV file...").ToString(), DefaultPath, TEXT(""), TEXT("String Table CSV (*.csv)|*.csv"), EFileDialogFlags::None, OutFiles )) { UStringTable* StringTable = Cast(GetEditingObject()); if (StringTable) { StringTable->GetStringTable()->ExportStrings(OutFiles[0]); } } } return FReply::Handled(); } #undef LOCTEXT_NAMESPACE