// Copyright Epic Games, Inc. All Rights Reserved. #include "Framework/Docking/LayoutService.h" #include "HAL/FileManager.h" #include "Internationalization/Regex.h" #include "Misc/App.h" #include "Misc/ConfigCacheIni.h" #include "Misc/FileHelper.h" #include "Misc/StringUtility.h" #include "Dom/JsonObject.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #include "String/CaseConversion.h" DEFINE_LOG_CATEGORY_STATIC(LogLayoutService, Log, All); const TCHAR* DefaultEditorLayoutsSectionName = TEXT("EditorLayouts"); static const TCHAR* GetEditorLayoutsSectionName() { static FString EditorLayoutsSectionName; if (EditorLayoutsSectionName.IsEmpty()) { GConfig->GetString(TEXT("Slate"), TEXT("EditorLayoutsSectionName"), EditorLayoutsSectionName, GEditorIni); EditorLayoutsSectionName.TrimStartAndEndInline(); if (EditorLayoutsSectionName.IsEmpty()) { EditorLayoutsSectionName = DefaultEditorLayoutsSectionName; } } return *EditorLayoutsSectionName; } static bool GetConfigString(const TCHAR* Key, FString& Value, const FString& Filename, bool bAllowFallback = true) { if (!GConfig->GetString(GetEditorLayoutsSectionName(), Key, Value, Filename)) { if (bAllowFallback && GetEditorLayoutsSectionName() != DefaultEditorLayoutsSectionName) { return GConfig->GetString(DefaultEditorLayoutsSectionName, Key, Value, Filename); } return false; } // Old custom layouts may have an incorrect name, so account for that here. if (const bool bIsUserLayout = Filename.StartsWith(FPaths::EngineUserLayoutDir())) { // Inject corrected label into result FRegexPattern Pattern(TEXT("\"\\w+\"")); // words between commas FRegexMatcher Matcher(Pattern, Value); int32 MatchStart = INDEX_NONE; int32 MatchEnd = INDEX_NONE; // We want the last match, so run until none found and use the last result while (Matcher.FindNext()) { MatchStart = Matcher.GetMatchBeginning(); MatchEnd = Matcher.GetMatchEnding(); } if (MatchStart != INDEX_NONE && MatchEnd != INDEX_NONE) { Value = Value.Left(MatchStart + 1) + FString(WriteToString<16>(UE::String::PascalCase(FPaths::GetBaseFilename(Filename)))) + Value.Right(Value.Len() - MatchEnd + 1); } } return true; } static bool GetConfigSection(TArray& Result, const FString& Filename, bool bAllowFallback = true) { if (!GConfig->GetSection(GetEditorLayoutsSectionName(), Result, Filename)) { if (bAllowFallback && GetEditorLayoutsSectionName() != DefaultEditorLayoutsSectionName) { return GConfig->GetSection(DefaultEditorLayoutsSectionName, Result, Filename); } return false; } return true; } static const FConfigSection* GetConfigSectionPrivate(const bool Force, const FString& Filename, bool bAllowFallback = true) { const FConfigSection* FoundSection = GConfig->GetSection(GetEditorLayoutsSectionName(), /*Force*/false, Filename); if (FoundSection) { if (bAllowFallback && GetEditorLayoutsSectionName() != DefaultEditorLayoutsSectionName) { FoundSection = GConfig->GetSection(DefaultEditorLayoutsSectionName, /*Force*/false, Filename); } } return FoundSection; } static FString PrepareLayoutStringForIni(const FString& LayoutString) { // Have to store braces as parentheses due to braces causing ini issues return LayoutString .Replace(TEXT("{"), TEXT("(")) .Replace(TEXT("}"), TEXT(")")) .Replace(TEXT("\r"), TEXT("")) .Replace(TEXT("\n"), TEXT("")) .Replace(TEXT("\t"), TEXT("")); } static FString GetLayoutStringFromIni(const FString& LayoutString, bool& bOutIsJson) { if (FTextStringHelper::IsComplexText(*LayoutString)) { bOutIsJson = false; return LayoutString; } // Revert parenthesis to braces, from ini readable to Json readable bOutIsJson = true; return LayoutString .Replace(TEXT("("), TEXT("{")) .Replace(TEXT(")"), TEXT("}")) .Replace(TEXT("\\") LINE_TERMINATOR, LINE_TERMINATOR); } const FString& FLayoutSaveRestore::GetAdditionalLayoutConfigIni() { static const FString IniSectionAdditionalConfig = TEXT("SlateAdditionalLayoutConfig"); return IniSectionAdditionalConfig; } static TSharedPtr ConvertSectionToJson(const TArray& SectionStrings) { TSharedPtr RootObject = MakeShared(); for (const FString& SectionPair : SectionStrings) { FString Key, Value; if (SectionPair.Split(TEXT("="), &Key, &Value)) { bool bIsJson = true; Value = GetLayoutStringFromIni(Value, bIsJson); if (bIsJson) { TSharedPtr ChildObject = MakeShared(); TSharedRef> Reader = TJsonReaderFactory<>::Create(Value); if (FJsonSerializer::Deserialize(Reader, ChildObject)) { RootObject->SetObjectField(Key, ChildObject); } else { bIsJson = false; } } if (!bIsJson) { RootObject->SetStringField(Key, Value); } } } return RootObject; } static FString GetLayoutJsonFileName(const FString& InConfigFileName) { const FString JsonFileName = FPaths::GetBaseFilename(InConfigFileName) + TEXT(".json"); #ifdef UE_SAVED_DIR_OVERRIDE const FString UserSettingsPath = FPaths::Combine(FPlatformProcess::UserSettingsDir(), TEXT(PREPROCESSOR_TO_STRING(UE_SAVED_DIR_OVERRIDE)), TEXT("Editor"), JsonFileName); #else const FString UserSettingsPath = FPaths::Combine(FPlatformProcess::UserSettingsDir(), FApp::GetEpicProductIdentifier(), TEXT("Editor"), JsonFileName); #endif return UserSettingsPath; } static TSharedPtr LoadJsonFile(const FString& InFileName) { TSharedPtr JsonObject; FString JsonContents; if (FFileHelper::LoadFileToString(JsonContents, *InFileName)) { TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonContents); FJsonSerializer::Deserialize(Reader, JsonObject); } return JsonObject; } static bool SaveJsonFile(const FString& InFileName, TSharedPtr JsonObject) { FString NewJsonContents; TSharedRef> Writer = TJsonWriterFactory<>::Create(&NewJsonContents); if (FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer)) { return FFileHelper::SaveStringToFile(NewJsonContents, *InFileName); } return false; } static void SaveLayoutToJson(const FString& InConfigFileName, const TSharedRef& InLayoutToSave) { const FString UserSettingsPath = GetLayoutJsonFileName(InConfigFileName); TSharedPtr AllLayoutsObject = LoadJsonFile(UserSettingsPath); if (!AllLayoutsObject.IsValid()) { // doesn't exist AllLayoutsObject = MakeShared(); } AllLayoutsObject->SetObjectField(InLayoutToSave->GetLayoutName().ToString(), InLayoutToSave->ToJson()); SaveJsonFile(UserSettingsPath, AllLayoutsObject); } static bool LoadLayoutFromJson(const FString& InConfigFileName, const FString& InLayoutName, TSharedPtr& OutLayout) { const FString LayoutJsonFile = GetLayoutJsonFileName(InConfigFileName); TSharedPtr JsonObject = LoadJsonFile(LayoutJsonFile); if (!JsonObject.IsValid()) { return false; } const TSharedPtr* LayoutJson = nullptr; if (JsonObject->TryGetObjectField(InLayoutName, LayoutJson)) { OutLayout = FTabManager::FLayout::NewFromJson(*LayoutJson); return true; } return false; } void FLayoutSaveRestore::SaveToConfig( const FString& InConfigFileName, const TSharedRef& InLayoutToSave ) { if (FGlobalTabmanager::Get()->CanSavePersistentLayouts()) { // Only save to config if it's not the FTabManager::FLayout::NullLayout if (InLayoutToSave->GetLayoutName() != FTabManager::FLayout::NullLayout->GetLayoutName()) { const FString LayoutAsString = PrepareLayoutStringForIni(InLayoutToSave->ToString()); GConfig->SetString(GetEditorLayoutsSectionName(), *InLayoutToSave->GetLayoutName().ToString(), *LayoutAsString, InConfigFileName); SaveLayoutToJson(InConfigFileName, InLayoutToSave); } } } TSharedRef FLayoutSaveRestore::LoadFromConfig(const FString& InConfigFileName, const TSharedRef& InDefaultLayout, const EOutputCanBeNullptr InPrimaryAreaOutputCanBeNullptr) { TArray DummyArray; return FLayoutSaveRestore::LoadFromConfigPrivate(InConfigFileName, InDefaultLayout, InPrimaryAreaOutputCanBeNullptr, false, DummyArray); } TSharedRef FLayoutSaveRestore::LoadFromConfig(const FString& InConfigFileName, const TSharedRef& InDefaultLayout, const EOutputCanBeNullptr InPrimaryAreaOutputCanBeNullptr, TArray& OutRemovedOlderLayoutVersions) { return FLayoutSaveRestore::LoadFromConfigPrivate(InConfigFileName, InDefaultLayout, InPrimaryAreaOutputCanBeNullptr, true, OutRemovedOlderLayoutVersions); } TSharedRef FLayoutSaveRestore::LoadFromConfigPrivate(const FString& InConfigFileName, const TSharedRef& InDefaultLayout, const EOutputCanBeNullptr InPrimaryAreaOutputCanBeNullptr, const bool bInRemoveOlderLayoutVersions, TArray& OutRemovedOlderLayoutVersions) { const FString LayoutNameString = InDefaultLayout->GetLayoutName().ToString(); TSharedPtr UserLayout; // First try to load from JSON, then INI if that does not exist if (!LoadLayoutFromJson(InConfigFileName, LayoutNameString, UserLayout)) { FString IniLayoutString; // If the Key (InDefaultLayout->GetLayoutName()) already exists in the section GetEditorLayoutsSectionName() of the file InConfigFileName, try to load the layout from that file GetConfigString(*LayoutNameString, IniLayoutString, InConfigFileName); bool bIsJson = false; UserLayout = FTabManager::FLayout::NewFromString( GetLayoutStringFromIni( IniLayoutString, bIsJson ) ); } if (UserLayout.IsValid()) { // Return UserLayout in the following 2 cases: // - By default (PrimaryAreaOutputCanBeNullptr = Never or IfNoTabValid) // - For the case of PrimaryAreaOutputCanBeNullptr = IfNoOpenTabValid, only if the primary area has at least a valid open tab TSharedPtr PrimaryArea = UserLayout->GetPrimaryArea().Pin(); if (PrimaryArea.IsValid() && (InPrimaryAreaOutputCanBeNullptr != EOutputCanBeNullptr::IfNoOpenTabValid || FGlobalTabmanager::Get()->HasValidOpenTabs(PrimaryArea.ToSharedRef()))) { return UserLayout.ToSharedRef(); } } // If the file layout could not be loaded and the caller wants to remove old fields else if (bInRemoveOlderLayoutVersions) { // If File and Section exist if (const FConfigSection* ConfigSection = GetConfigSectionPrivate(/*Force*/false, InConfigFileName)) { // If Key does not exist (i.e., Section does but not contain that Key) if (!ConfigSection->Find(*LayoutNameString)) { // Create LayoutKeyToRemove FString LayoutKeyToRemove; for (int32 Index = LayoutNameString.Len() - 1; Index > 0; --Index) { if (LayoutNameString[Index] != TCHAR('.') && (LayoutNameString[Index] < TCHAR('0') || LayoutNameString[Index] > TCHAR('9'))) { LayoutKeyToRemove = LayoutNameString.Left(Index+1); break; } } // Look for older versions of this Key OutRemovedOlderLayoutVersions.Empty(); for (const auto& SectionPair : *ConfigSection/*->ArrayOfStructKeys*/) { FString CurrentKey = SectionPair.Key.ToString(); if (CurrentKey.Len() > LayoutKeyToRemove.Len() && CurrentKey.Left(LayoutKeyToRemove.Len()) == LayoutKeyToRemove) { OutRemovedOlderLayoutVersions.Emplace(std::move(CurrentKey)); } } // Remove older versions of this Key for (const FString& KeyToRemove : OutRemovedOlderLayoutVersions) { if (GConfig->RemoveKey(GetEditorLayoutsSectionName(), *KeyToRemove, InConfigFileName)) { UE_LOG(LogLayoutService, Warning, TEXT("While key \"%s\" was not found, and older version exists (key \"%s\"). This means section \"%s\" was" " created with a previous version of UE and is no longer compatible. The old key has been removed and updated with the new one."), *LayoutNameString, *KeyToRemove, GetEditorLayoutsSectionName()); } else { UE_LOG(LogLayoutService, Warning, TEXT("Could not remove old layout key %s because it exists in the fallback section %s instead of the current section %s"), *KeyToRemove, DefaultEditorLayoutsSectionName, GetEditorLayoutsSectionName()); } } } } } return InDefaultLayout; } void FLayoutSaveRestore::SaveSectionToConfig(const FString& InConfigFileName, const FString& InSectionName, const FText& InSectionValue) { FString StrValue; FTextStringHelper::WriteToBuffer(StrValue, InSectionValue); GConfig->SetString(GetEditorLayoutsSectionName(), *InSectionName, *StrValue, InConfigFileName); const FString JsonFileName = GetLayoutJsonFileName(InConfigFileName); TSharedPtr JsonObject = LoadJsonFile(JsonFileName); if (JsonObject.IsValid()) { JsonObject->SetStringField(InSectionName, *StrValue); SaveJsonFile(JsonFileName, JsonObject); } } FText FLayoutSaveRestore::LoadSectionFromConfig(const FString& InConfigFileName, const FString& InSectionName, const bool bIsOptional) { FString ValueString; const FString JsonFileName = GetLayoutJsonFileName(InConfigFileName); TSharedPtr JsonObject = LoadJsonFile(JsonFileName); if (JsonObject.IsValid()) { // If optional, we check if the field exists first if (!bIsOptional || (bIsOptional && JsonObject->HasTypedField(InSectionName, EJson::String))) { ValueString = JsonObject->GetStringField(InSectionName); } } if (ValueString.IsEmpty()) { GetConfigString(*InSectionName, ValueString, InConfigFileName); } FText ValueText; FTextStringHelper::ReadFromBuffer(*ValueString, ValueText, GetEditorLayoutsSectionName()); return ValueText; } bool FLayoutSaveRestore::DuplicateConfig(const FString& SourceConfigFileName, const FString& TargetConfigFileName) { const bool bShouldReplace = true; const bool bCopyEvenIfReadOnly = true; const bool bCopyAttributes = false; // If true, we could copy the read-only flag of DefaultLayout.ini and cause save/load to stop working if (IFileManager::Get().Copy(*TargetConfigFileName, *SourceConfigFileName, bShouldReplace, bCopyEvenIfReadOnly, bCopyAttributes) == COPY_Fail) { return false; } // convert this layout to a JSON file TArray SectionPairs; GetConfigSection(SectionPairs, TargetConfigFileName); TSharedPtr RootObject = ConvertSectionToJson(SectionPairs); const FString TargetJsonFilename = GetLayoutJsonFileName(TargetConfigFileName); SaveJsonFile(TargetJsonFilename, RootObject); return true; } void FLayoutSaveRestore::MigrateConfig( const FString& OldConfigFileName, const FString& NewConfigFileName ) { TArray OldSectionStrings; // check whether any layout configuration needs to be migrated if (!GetConfigSection(OldSectionStrings, OldConfigFileName) || (OldSectionStrings.Num() == 0)) { return; } TArray NewSectionStrings; // migrate old configuration if a new layout configuration does not yet exist if (!GConfig->GetSection(GetEditorLayoutsSectionName(), NewSectionStrings, NewConfigFileName) || (NewSectionStrings.Num() == 0)) { FString Key, Value; for (const FString& SectionString : OldSectionStrings) { if (SectionString.Split(TEXT("="), &Key, &Value)) { GConfig->SetString(GetEditorLayoutsSectionName(), *Key, *Value, NewConfigFileName); } } } // remove old configuration if (!GConfig->EmptySection(GetEditorLayoutsSectionName(), OldConfigFileName)) { UE_LOG(LogLayoutService, Warning, TEXT("Could not remove old layout in %s because it is using the fallback section %s instead of the current section %s. New layout file will still be created."), *OldConfigFileName, DefaultEditorLayoutsSectionName, GetEditorLayoutsSectionName()); } GConfig->Flush(false, OldConfigFileName); GConfig->Flush(false, NewConfigFileName); // migrate layout to JSON as well const FString NewLayoutJsonFileName = GetLayoutJsonFileName(NewConfigFileName); TSharedPtr JsonObject = LoadJsonFile(NewLayoutJsonFileName); if (!JsonObject.IsValid() || JsonObject->Values.Num() == 0) { TSharedPtr RootObject = ConvertSectionToJson(OldSectionStrings); SaveJsonFile(NewLayoutJsonFileName, RootObject); } } bool FLayoutSaveRestore::IsValidConfig(const FString& InConfigFileName, bool bAllowFallback) { if (GConfig->DoesSectionExist(GetEditorLayoutsSectionName(), *InConfigFileName)) { return true; } else if (bAllowFallback && GConfig->DoesSectionExist(DefaultEditorLayoutsSectionName, *InConfigFileName)) { return true; } const FString JsonFileName = GetLayoutJsonFileName(InConfigFileName); return IFileManager::Get().FileExists(*JsonFileName); }