// Copyright Epic Games, Inc. All Rights Reserved. #include "FeedbackContextEditor.h" #include "HAL/PlatformSplash.h" #include "Modules/ModuleManager.h" #include "Fonts/FontMeasure.h" #include "Framework/Application/SlateApplication.h" #include "Widgets/Layout/SBorder.h" #include "Widgets/Notifications/SProgressBar.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/Layout/SBox.h" #include "Widgets/Input/SButton.h" #include "Styling/CoreStyle.h" #include "EditorStyleSet.h" #include "Editor.h" #include "Dialogs/SBuildProgress.h" #include "Interfaces/IMainFrameModule.h" #include "HAL/PlatformApplicationMisc.h" #include "Engine/Engine.h" #include "StudioAnalytics.h" #include "AnalyticsEventAttribute.h" /** Called to cancel the slow task activity */ DECLARE_DELEGATE(FOnCancelClickedDelegate); /** Called when checking if the cancel button should be enabled */ DECLARE_DELEGATE_RetVal(bool, FReceiveUserCancelDelegate); /** * Simple "slow task" widget */ class SSlowTaskWidget: public SBorder { /** The maximum number of secondary bars to show on the widget */ static const int32 MaxNumSecondaryBars = 3; /** The width of the dialog, and horizontal padding */ static const int32 FixedWidth = 600, FixedPaddingH = 24; /** The heights of the progress bars on this widget */ static const int32 MainBarHeight = 12, SecondaryBarHeight = 3; public: SLATE_BEGIN_ARGS(SSlowTaskWidget) {} /** Called to when an asset is clicked */ SLATE_EVENT(FOnCancelClickedDelegate, OnCancelClickedDelegate) /** Called when checking if the cancel button should be enabled */ SLATE_EVENT(FReceiveUserCancelDelegate, ReceiveUserCancelDelegate) /** The feedback scope stack that we are presenting to the user */ SLATE_ARGUMENT(TWeakPtr, ScopeStack) SLATE_END_ARGS() /** Construct this widget */ void Construct(const FArguments& InArgs) { OnCancelClickedDelegate = InArgs._OnCancelClickedDelegate; ReceiveUserCancelDelegate = InArgs._ReceiveUserCancelDelegate; WeakStack = InArgs._ScopeStack; // This is a temporary widget that needs to be updated over its entire lifespan => has an active timer registered for its entire lifespan RegisterActiveTimer(0.f, FWidgetActiveTimerDelegate::CreateSP(this, &SSlowTaskWidget::UpdateProgress)); TSharedRef VerticalBox = SNew(SVerticalBox) // Construct the main progress bar and text + SVerticalBox::Slot() .AutoHeight() [SNew(SVerticalBox) + SVerticalBox::Slot() .AutoHeight() .Padding(FMargin(0, 0, 0, 5.f)) .VAlign(VAlign_Center) [SNew(SBox) .HeightOverride(24.f) [SNew(SHorizontalBox) + SHorizontalBox::Slot() [SNew(STextBlock) .AutoWrapText(true) .Text(this, &SSlowTaskWidget::GetProgressText, 0) // The main font size dynamically changes depending on the content .Font(this, &SSlowTaskWidget::GetMainTextFont)] + SHorizontalBox::Slot() .Padding(FMargin(5.f, 0, 0, 0)) .AutoWidth() [SNew(STextBlock) .Text(this, &SSlowTaskWidget::GetPercentageText) // The main font size dynamically changes depending on the content .Font(FCoreStyle::GetDefaultFontStyle("Light", 14))]]] + SVerticalBox::Slot() .AutoHeight() [SNew(SBox) .HeightOverride(MainBarHeight) [SNew(SProgressBar) .BorderPadding(FVector2D::ZeroVector) .Percent(this, &SSlowTaskWidget::GetProgressFraction, 0) .BackgroundImage(FEditorStyle::GetBrush("ProgressBar.ThinBackground")) .FillImage(FEditorStyle::GetBrush("ProgressBar.ThinFill"))]]] // Secondary progress bars + SVerticalBox::Slot() .AutoHeight() .Padding(FMargin(0.f, 8.f, 0.f, 0.f)) [SAssignNew(SecondaryBars, SVerticalBox)]; if (OnCancelClickedDelegate.IsBound()) { VerticalBox->AddSlot() .AutoHeight() .HAlign(HAlign_Center) .Padding(10.0f, 7.0f) [SNew(SButton) .Text(NSLOCTEXT("FeedbackContextProgress", "Cancel", "Cancel")) .HAlign(EHorizontalAlignment::HAlign_Center) .OnClicked(this, &SSlowTaskWidget::OnCancel) .IsEnabled(this, &SSlowTaskWidget::GetCancelEnabledState)]; } SBorder::Construct(SBorder::FArguments() .BorderImage(FEditorStyle::GetBrush("Menu.Background")) .VAlign(VAlign_Center) .Padding(FMargin(FixedPaddingH)) [SNew(SBox).WidthOverride(FixedWidth) [VerticalBox]]); // Make sure all our bars are set up UpdateDynamicProgressBars(); } private: /** Active timer to update the progress bars */ EActiveTimerReturnType UpdateProgress(double InCurrentTime, float InDeltaTime) { UpdateDynamicProgressBars(); return EActiveTimerReturnType::Continue; } /** Updates the dynamic progress bars for this widget */ void UpdateDynamicProgressBars() { auto ScopeStack = WeakStack.Pin(); if (!ScopeStack.IsValid()) { return; } static const double VisibleScopeThreshold = 0.5; DynamicProgressIndices.Reset(); // Always show the first one DynamicProgressIndices.Add(0); for (int32 Index = 1; Index < ScopeStack->Num() && DynamicProgressIndices.Num() <= MaxNumSecondaryBars - 1; ++Index) { const auto* Scope = (*ScopeStack)[Index]; if (Scope->Visibility == ESlowTaskVisibility::ForceVisible) { DynamicProgressIndices.Add(Index); } else if (Scope->Visibility == ESlowTaskVisibility::Default && !Scope->DefaultMessage.IsEmpty()) { const auto TimeOpen = FPlatformTime::Seconds() - Scope->StartTime; // We only show visible scopes if they have been opened a while if (TimeOpen > VisibleScopeThreshold) { DynamicProgressIndices.Add(Index); } } } // Create progress bars for anything that we haven't cached yet // We don't destroy old widgets, they just remain ghosted until shown again for (int32 Index = SecondaryBars->GetChildren()->Num() + 1; Index < DynamicProgressIndices.Num(); ++Index) { CreateSecondaryBar(Index); } } /** Create a progress bar for the specified index */ void CreateSecondaryBar(int32 Index) { SecondaryBars->AddSlot() .Padding(0.f, 16.f, 0.f, 0.f) [SNew(SVerticalBox) .Visibility(this, &SSlowTaskWidget::GetSecondaryBarVisibility, Index) + SVerticalBox::Slot() .Padding(0.f, 0.f, 0.f, 4.f) .AutoHeight() [SNew(STextBlock) .Text(this, &SSlowTaskWidget::GetProgressText, Index) .Font(FCoreStyle::GetDefaultFontStyle("Regular", 9)) .ColorAndOpacity(FSlateColor::UseSubduedForeground())] + SVerticalBox::Slot() .AutoHeight() [SNew(SBox) .HeightOverride(SecondaryBarHeight) [SNew(SBorder) .Padding(0) .BorderImage(FEditorStyle::GetBrush("NoBorder")) .ColorAndOpacity(this, &SSlowTaskWidget::GetSecondaryProgressBarTint, Index) [SNew(SProgressBar) .BorderPadding(FVector2D::ZeroVector) .Percent(this, &SSlowTaskWidget::GetProgressFraction, Index) .BackgroundImage(FEditorStyle::GetBrush("ProgressBar.ThinBackground")) .FillImage(FEditorStyle::GetBrush("ProgressBar.ThinFill"))]]]]; } private: /** The main text that we will display in the window */ FText GetPercentageText() const { auto ScopeStack = WeakStack.Pin(); if (ScopeStack.IsValid()) { const float ProgressInterp = ScopeStack->GetProgressFraction(0); return FText::AsPercent(ProgressInterp); } return FText(); } /** Calculate the best font to display the main text with */ FSlateFontInfo GetMainTextFont() const { TSharedRef MeasureService = FSlateApplication::Get().GetRenderer()->GetFontMeasureService(); const int32 MaxFontSize = 14; FSlateFontInfo FontInfo = FCoreStyle::GetDefaultFontStyle("Light", MaxFontSize); const FText MainText = GetProgressText(0); const int32 MaxTextWidth = FixedWidth - FixedPaddingH * 2; while (FontInfo.Size > 9 && MeasureService->Measure(MainText, FontInfo).X > MaxTextWidth) { FontInfo.Size -= 2; } return FontInfo; } /** Get the tint for a secondary progress bar */ FLinearColor GetSecondaryProgressBarTint(int32 Index) const { auto ScopeStack = WeakStack.Pin(); if (ScopeStack.IsValid()) { if (!DynamicProgressIndices.IsValidIndex(Index) || !ScopeStack->IsValidIndex(DynamicProgressIndices[Index])) { return FLinearColor::White.CopyWithNewOpacity(0.25f); } } return FLinearColor::White; } /** Get the fractional percentage of completion for a progress bar */ TOptional GetProgressFraction(int32 Index) const { auto ScopeStack = WeakStack.Pin(); if (ScopeStack.IsValid()) { if (DynamicProgressIndices.IsValidIndex(Index) && ScopeStack->IsValidIndex(DynamicProgressIndices[Index])) { return ScopeStack->GetProgressFraction(DynamicProgressIndices[Index]); } } return TOptional(); } /** Get the text to display for a progress bar */ FText GetProgressText(int32 Index) const { auto ScopeStack = WeakStack.Pin(); if (ScopeStack.IsValid()) { if (DynamicProgressIndices.IsValidIndex(Index) && ScopeStack->IsValidIndex(DynamicProgressIndices[Index])) { return (*ScopeStack)[DynamicProgressIndices[Index]]->GetCurrentMessage(); } } return FText(); } EVisibility GetSecondaryBarVisibility(int32 Index) const { return DynamicProgressIndices.IsValidIndex(Index) ? EVisibility::HitTestInvisible : EVisibility::Collapsed; } /** Called when the cancel button is clicked */ FReply OnCancel() { OnCancelClickedDelegate.ExecuteIfBound(); return FReply::Handled(); } bool GetCancelEnabledState() const { if (ReceiveUserCancelDelegate.IsBound()) { return !ReceiveUserCancelDelegate.Execute(); } return true; } private: /** Delegate to invoke if the user clicks cancel */ FOnCancelClickedDelegate OnCancelClickedDelegate; /** Delegate that returns if the cancel state is already active */ FReceiveUserCancelDelegate ReceiveUserCancelDelegate; /** The scope stack that we are reflecting */ TWeakPtr WeakStack; /** The vertical box containing the secondary progress bars */ TSharedPtr SecondaryBars; /** Array mapping progress bar index -> scope stack index. Updated every tick. */ TArray DynamicProgressIndices; }; /** Static integer definitions required on some builds where the linker needs access to these */ const int32 SSlowTaskWidget::MaxNumSecondaryBars; const int32 SSlowTaskWidget::FixedWidth; const int32 SSlowTaskWidget::FixedPaddingH; ; const int32 SSlowTaskWidget::MainBarHeight; const int32 SSlowTaskWidget::SecondaryBarHeight; static void TickSlate(TSharedPtr SlowTaskWindow) { // Avoid re-entrancy by ticking the active modal window again. This can happen if thhe slow task window is open and a sibling modal window is open as well. We only tick slate if we are the active modal window or a child of the active modal window if (SlowTaskWindow.IsValid() && (FSlateApplication::Get().GetActiveModalWindow() == SlowTaskWindow || SlowTaskWindow->IsDescendantOf(FSlateApplication::Get().GetActiveModalWindow()))) { // Mark begin frame if (GIsRHIInitialized) { ENQUEUE_RENDER_COMMAND(BeginFrameCmd) ([](FRHICommandListImmediate& RHICmdList) { RHICmdList.BeginFrame(); }); } // Tick Slate application FSlateApplication::Get().Tick(); // End frame so frame fence number gets incremented if (GIsRHIInitialized) { ENQUEUE_RENDER_COMMAND(EndFrameCmd) ([](FRHICommandListImmediate& RHICmdList) { RHICmdList.EndFrame(); }); } // Sync the game thread and the render thread. This is needed if many StatusUpdate are called FSlateApplication::Get().GetRenderer()->Sync(); } } FFeedbackContextEditor::FFeedbackContextEditor() : HasTaskBeenCancelled(false) { } void FFeedbackContextEditor::Serialize(const TCHAR* V, ELogVerbosity::Type Verbosity, const class FName& Category) { if (!GLog->IsRedirectingTo(this)) { GLog->Serialize(V, Verbosity, Category); } } void FFeedbackContextEditor::StartSlowTask(const FText& Task, bool bShowCancelButton) { FFeedbackContext::StartSlowTask(Task, bShowCancelButton); if (GEditor) { // reset the cancellation flag HasTaskBeenCancelled = false; // If there is a pie window and it is active attempt to parent any slow task dialogs to it to prevent the game window from falling behind due to a slowtask window opening. TSharedPtr ParentWindow; if (FWorldContext* PieWorldContext = GEditor->GetPIEWorldContext()) { FSlatePlayInEditorInfo* SlatePlayInEditorSession = GEditor->SlatePlayInEditorMap.Find(PieWorldContext->ContextHandle); if (SlatePlayInEditorSession && SlatePlayInEditorSession->SlatePlayInEditorWindow.IsValid()) { if (FSlateApplication::Get().GetActiveTopLevelWindow() == SlatePlayInEditorSession->SlatePlayInEditorWindow) { ParentWindow = SlatePlayInEditorSession->SlatePlayInEditorWindow.Pin(); } } } // Attempt to parent the slow task window to the slate main frame if (!ParentWindow.IsValid() && FModuleManager::Get().IsModuleLoaded("MainFrame")) { IMainFrameModule& MainFrame = FModuleManager::LoadModuleChecked("MainFrame"); ParentWindow = MainFrame.GetParentWindow(); } if (ParentWindow.IsValid()) { GSlowTaskOccurred = GIsSlowTask; // Don't show the progress dialog if the Build Progress dialog is already visible bool bProgressWindowShown = BuildProgressWidget.IsValid(); // Don't show the progress dialog if a Slate menu is currently open const bool bHaveOpenMenu = FSlateApplication::Get().AnyMenusVisible(); if (bHaveOpenMenu) { UE_LOG(LogSlate, Log, TEXT("Prevented a slow task dialog from being summoned while a context menu was open")); } if (!bProgressWindowShown && !bHaveOpenMenu && FSlateApplication::Get().CanDisplayWindows()) { FOnCancelClickedDelegate OnCancelClicked; if (bShowCancelButton) { // The cancel button is only displayed if a delegate is bound to it. OnCancelClicked = FOnCancelClickedDelegate::CreateRaw(this, &FFeedbackContextEditor::OnUserCancel); } const bool bFocusAndActivate = FPlatformApplicationMisc::IsThisApplicationForeground(); FReceiveUserCancelDelegate ReceiveUserCancelDelegate = FReceiveUserCancelDelegate::CreateRaw(this, &FFeedbackContextEditor::ReceivedUserCancel); TSharedRef SlowTaskWindowRef = SNew(SWindow) .SizingRule(ESizingRule::Autosized) .AutoCenter(EAutoCenter::PreferredWorkArea) .IsPopupWindow(true) .CreateTitleBar(true) .ActivationPolicy(bFocusAndActivate ? EWindowActivationPolicy::Always : EWindowActivationPolicy::Never) .FocusWhenFirstShown(bFocusAndActivate); SlowTaskWindowRef->SetContent( SNew(SSlowTaskWidget) .ScopeStack(GetScopeStackSharedPtr()) .OnCancelClickedDelegate(OnCancelClicked) .ReceiveUserCancelDelegate(ReceiveUserCancelDelegate)); SlowTaskWindow = SlowTaskWindowRef; const bool bSlowTask = true; FSlateApplication::Get().AddModalWindow(SlowTaskWindowRef, ParentWindow, bSlowTask); SlowTaskWindowRef->ShowWindow(); SlowTaskStartTime = FStudioAnalytics::GetAnalyticSeconds(); TickSlate(SlowTaskWindow.Pin()); } FPlatformSplash::SetSplashText(SplashTextType::StartupProgress, *Task.ToString()); } } } void FFeedbackContextEditor::FinalizeSlowTask() { auto Window = SlowTaskWindow.Pin(); if (Window.IsValid()) { const double SlowTaskDialogTime = FStudioAnalytics::GetAnalyticSeconds() - SlowTaskStartTime; const FText TaskName = ScopeStack.Num() > 0 ? ScopeStack[0]->DefaultMessage : FText::GetEmpty(); FStudioAnalytics::FireEvent_Loading(TEXT("SlowTaskDialog"), SlowTaskDialogTime, {FAnalyticsEventAttribute(TEXT("Task"), TaskName.ToString())}); Window->SetContent(SNullWidget::NullWidget); Window->RequestDestroyWindow(); SlowTaskWindow.Reset(); } FFeedbackContext::FinalizeSlowTask(); } void FFeedbackContextEditor::ProgressReported(const float TotalProgressInterp, FText DisplayMessage) { if (!(FPlatformSplash::IsShown() || BuildProgressWidget.IsValid() || SlowTaskWindow.IsValid())) { return; } // Clean up deferred cleanup objects from rendering thread every once in a while. static double LastTimePendingCleanupObjectsWhereDeleted; if (FPlatformTime::Seconds() - LastTimePendingCleanupObjectsWhereDeleted > 1) { // Get list of objects that are pending cleanup. FPendingCleanupObjects* PendingCleanupObjects = GetPendingCleanupObjects(); // Flush rendering commands in the queue. FlushRenderingCommands(); // It is now safe to delete the pending clean objects. delete PendingCleanupObjects; // Keep track of time this operation was performed so we don't do it too often. LastTimePendingCleanupObjectsWhereDeleted = FPlatformTime::Seconds(); } if (BuildProgressWidget.IsValid() || SlowTaskWindow.IsValid()) { // CanDisplayWindows can be slow when called repeatedly, so we only call it if a window is open if (!FSlateApplication::Get().CanDisplayWindows()) { return; } if (BuildProgressWidget.IsValid()) { if (!DisplayMessage.IsEmpty()) { BuildProgressWidget->SetBuildStatusText(DisplayMessage); } BuildProgressWidget->SetBuildProgressPercent(TotalProgressInterp * 100, 100); TickSlate(BuildProgressWindow.Pin()); } else if (SlowTaskWindow.IsValid()) { TickSlate(SlowTaskWindow.Pin()); } } else if (FPlatformSplash::IsShown()) { // Always show the top-most message for (int i = ScopeStack.Num() - 1; i > -1; --i) { const FText ThisMessage = ScopeStack[i]->GetCurrentMessage(); if (!ThisMessage.IsEmpty()) { DisplayMessage = ThisMessage; break; } } if (!DisplayMessage.IsEmpty()) { const int32 DotCount = 4; const float MinTimeBetweenUpdates = 0.2f; static double LastUpdateTime = -100000.0; static int32 DotProgress = 0; const double CurrentTime = FPlatformTime::Seconds(); if (CurrentTime - LastUpdateTime >= MinTimeBetweenUpdates) { LastUpdateTime = CurrentTime; DotProgress = (DotProgress + 1) % DotCount; } FString NewDisplayMessage = DisplayMessage.ToString(); NewDisplayMessage.RemoveFromEnd(TEXT("...")); for (int32 DotIndex = 0; DotIndex <= DotCount; ++DotIndex) { if (DotIndex <= DotProgress) { NewDisplayMessage.AppendChar(TCHAR('.')); } else { NewDisplayMessage.AppendChar(TCHAR(' ')); } } DisplayMessage = FText::FromString(FString::Printf(TEXT("%3i%% - %s"), int(TotalProgressInterp * 100.f), *NewDisplayMessage)); } FPlatformSplash::SetSplashText(SplashTextType::StartupProgress, *DisplayMessage.ToString()); } } bool FFeedbackContextEditor::ReceivedUserCancel(void) { return HasTaskBeenCancelled; } void FFeedbackContextEditor::OnUserCancel() { HasTaskBeenCancelled = true; } /** * Show the Build Progress Window * @return Handle to the Build Progress Widget created */ TWeakPtr FFeedbackContextEditor::ShowBuildProgressWindow() { TSharedRef BuildProgressWindowRef = SNew(SWindow) .ClientSize(FVector2D(500, 200)) .IsPopupWindow(true); BuildProgressWidget = SNew(SBuildProgressWidget); BuildProgressWindowRef->SetContent(BuildProgressWidget.ToSharedRef()); BuildProgressWindow = BuildProgressWindowRef; // Attempt to parent the slow task window to the slate main frame TSharedPtr ParentWindow; if (FModuleManager::Get().IsModuleLoaded("MainFrame")) { IMainFrameModule& MainFrame = FModuleManager::LoadModuleChecked("MainFrame"); ParentWindow = MainFrame.GetParentWindow(); } FSlateApplication::Get().AddModalWindow(BuildProgressWindowRef, ParentWindow, true); BuildProgressWindowRef->ShowWindow(); BuildProgressWidget->MarkBuildStartTime(); if (FSlateApplication::Get().CanDisplayWindows()) { TickSlate(BuildProgressWindow.Pin()); } return BuildProgressWidget; } /** Close the Build Progress Window */ void FFeedbackContextEditor::CloseBuildProgressWindow() { if (BuildProgressWindow.IsValid() && BuildProgressWidget.IsValid()) { BuildProgressWindow.Pin()->RequestDestroyWindow(); BuildProgressWindow.Reset(); BuildProgressWidget.Reset(); } } bool FFeedbackContextEditor::IsPlayingInEditor() const { return (GIsPlayInEditorWorld || (GEditor && GEditor->PlayWorld != nullptr)); }