// Copyright 1998-2018 Epic Games, Inc. All Rights Reserved. #include "SOutputLog.h" #include "Framework/Text/TextRange.h" #include "Framework/Text/IRun.h" #include "Framework/Text/TextLayout.h" #include "Misc/ConfigCacheIni.h" #include "Misc/OutputDeviceHelper.h" #include "SlateOptMacros.h" #include "Textures/SlateIcon.h" #include "Framework/Commands/UIAction.h" #include "Framework/Text/SlateTextLayout.h" #include "Framework/Text/SlateTextRun.h" #include "Framework/Application/SlateApplication.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/Input/SMenuAnchor.h" #include "Framework/MultiBox/MultiBoxBuilder.h" #include "Widgets/Input/SMultiLineEditableTextBox.h" #include "Widgets/Input/SComboButton.h" #include "Widgets/Views/SListView.h" #include "EditorStyleSet.h" #include "Classes/EditorStyleSettings.h" #include "Widgets/Input/SSearchBox.h" #include "Features/IModularFeatures.h" #include "Misc/CoreDelegates.h" #define LOCTEXT_NAMESPACE "SOutputLog" /** Expression context to test the given messages against the current text filter */ class FLogFilter_TextFilterExpressionContext : public ITextFilterExpressionContext { public: explicit FLogFilter_TextFilterExpressionContext(const FLogMessage& InMessage) : Message(&InMessage) {} /** Test the given value against the strings extracted from the current item */ virtual bool TestBasicStringExpression(const FTextFilterString& InValue, const ETextFilterTextComparisonMode InTextComparisonMode) const override { return TextFilterUtils::TestBasicStringExpression(*Message->Message, InValue, InTextComparisonMode); } /** * Perform a complex expression test for the current item * No complex expressions in this case - always returns false */ virtual bool TestComplexExpression(const FName& InKey, const FTextFilterString& InValue, const ETextFilterComparisonOperation InComparisonOperation, const ETextFilterTextComparisonMode InTextComparisonMode) const override { return false; } private: /** Message that is being filtered */ const FLogMessage* Message; }; SConsoleInputBox::SConsoleInputBox() : bIgnoreUIUpdate(false) , bHasTicked(false) { } BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION void SConsoleInputBox::Construct(const FArguments& InArgs) { OnConsoleCommandExecuted = InArgs._OnConsoleCommandExecuted; ConsoleCommandCustomExec = InArgs._ConsoleCommandCustomExec; OnCloseConsole = InArgs._OnCloseConsole; if (!ConsoleCommandCustomExec.IsBound()) // custom execs always show the default executor in the UI (which has the selector disabled) { FString PreferredCommandExecutorStr; if (GConfig->GetString(TEXT("OutputLog"), TEXT("PreferredCommandExecutor"), PreferredCommandExecutorStr, GEditorPerProjectIni)) { PreferredCommandExecutorName = *PreferredCommandExecutorStr; } } SyncActiveCommandExecutor(); IModularFeatures::Get().OnModularFeatureRegistered().AddSP(this, &SConsoleInputBox::OnCommandExecutorRegistered); IModularFeatures::Get().OnModularFeatureUnregistered().AddSP(this, &SConsoleInputBox::OnCommandExecutorUnregistered); EPopupMethod PopupMethod = GIsEditor ? EPopupMethod::CreateNewWindow : EPopupMethod::UseCurrentWindow; ChildSlot [ SAssignNew( SuggestionBox, SMenuAnchor ) .Method(PopupMethod) .Placement( InArgs._SuggestionListPlacement ) [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .AutoWidth() .Padding(FMargin(0.0f, 0.0f, 4.0f, 0.0f)) [ SNew(SComboButton) .IsEnabled(this, &SConsoleInputBox::IsCommandExecutorMenuEnabled) .ComboButtonStyle(FEditorStyle::Get(), "GenericFilters.ComboButtonStyle") .ForegroundColor(FLinearColor::White) .ContentPadding(0) .OnGetMenuContent(this, &SConsoleInputBox::GetCommandExecutorMenuContent) .ButtonContent() [ SNew(STextBlock) .Text(this, &SConsoleInputBox::GetActiveCommandExecutorDisplayName) ] ] +SHorizontalBox::Slot() [ SAssignNew(InputText, SMultiLineEditableTextBox) .Font(FEditorStyle::Get().GetWidgetStyle("Log.Normal").Font) .HintText(this, &SConsoleInputBox::GetActiveCommandExecutorHintText) .AllowMultiLine(this, &SConsoleInputBox::GetActiveCommandExecutorAllowMultiLine) .OnTextCommitted(this, &SConsoleInputBox::OnTextCommitted) .OnTextChanged(this, &SConsoleInputBox::OnTextChanged) .OnKeyCharHandler(this, &SConsoleInputBox::OnKeyCharHandler) .OnKeyDownHandler(this, &SConsoleInputBox::OnKeyDownHandler) .OnIsTypedCharValid(FOnIsTypedCharValid::CreateLambda([](const TCHAR InCh) { return true; })) // allow tabs to be typed into the field .ClearKeyboardFocusOnCommit(false) .ModiferKeyForNewLine(EModifierKey::Shift) ] ] .MenuContent ( SNew(SBorder) .BorderImage(FEditorStyle::GetBrush("Menu.Background")) .Padding( FMargin(2) ) [ SNew(SBox) .HeightOverride(250) // avoids flickering, ideally this would be adaptive to the content without flickering .MinDesiredWidth(300) .MaxDesiredWidth(this, &SConsoleInputBox::GetSelectionListMaxWidth) [ SAssignNew(SuggestionListView, SListView< TSharedPtr >) .ListItemsSource(&Suggestions.SuggestionsList) .SelectionMode( ESelectionMode::Single ) // Ideally the mouse over would not highlight while keyboard controls the UI .OnGenerateRow(this, &SConsoleInputBox::MakeSuggestionListItemWidget) .OnSelectionChanged(this, &SConsoleInputBox::SuggestionSelectionChanged) .ItemHeight(18) ] ] ) ]; } END_SLATE_FUNCTION_BUILD_OPTIMIZATION void SConsoleInputBox::Tick( const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime ) { bHasTicked = true; if (!GIntraFrameDebuggingGameThread && !IsEnabled()) { SetEnabled(true); } else if (GIntraFrameDebuggingGameThread && IsEnabled()) { SetEnabled(false); } } void SConsoleInputBox::SuggestionSelectionChanged(TSharedPtr NewValue, ESelectInfo::Type SelectInfo) { if(bIgnoreUIUpdate) { return; } Suggestions.SelectedSuggestion = Suggestions.SuggestionsList.IndexOfByPredicate([&NewValue](const TSharedPtr& InSuggestion) { return InSuggestion == NewValue; }); MarkActiveSuggestion(); // If the user selected this suggestion by clicking on it, then go ahead and close the suggestion // box as they've chosen the suggestion they're interested in. if( SelectInfo == ESelectInfo::OnMouseClick ) { SuggestionBox->SetIsOpen( false ); } // Ideally this would set the focus back to the edit control // FWidgetPath WidgetToFocusPath; // FSlateApplication::Get().GeneratePathToWidgetUnchecked( InputText.ToSharedRef(), WidgetToFocusPath ); // FSlateApplication::Get().SetKeyboardFocus( WidgetToFocusPath, EFocusCause::SetDirectly ); } FOptionalSize SConsoleInputBox::GetSelectionListMaxWidth() const { // Limit the width of the suggestions list to the work area that this widget currently resides on const FSlateRect WidgetRect(GetCachedGeometry().GetAbsolutePosition(), GetCachedGeometry().GetAbsolutePosition() + GetCachedGeometry().GetAbsoluteSize()); const FSlateRect WidgetWorkArea = FSlateApplication::Get().GetWorkArea(WidgetRect); return FMath::Max(300.0f, WidgetWorkArea.GetSize().X - 12.0f); } TSharedRef SConsoleInputBox::MakeSuggestionListItemWidget(TSharedPtr Text, const TSharedRef& OwnerTable) { check(Text.IsValid()); FString SanitizedText = *Text; SanitizedText.ReplaceInline(TEXT("\r\n"), TEXT("\n"), ESearchCase::CaseSensitive); SanitizedText.ReplaceInline(TEXT("\r"), TEXT(" "), ESearchCase::CaseSensitive); SanitizedText.ReplaceInline(TEXT("\n"), TEXT(" "), ESearchCase::CaseSensitive); return SNew(STableRow< TSharedPtr >, OwnerTable) [ SNew(STextBlock) .Text(FText::FromString(SanitizedText)) .TextStyle(FEditorStyle::Get(), "Log.Normal") .HighlightText(Suggestions.SuggestionsHighlight) ]; } void SConsoleInputBox::OnTextChanged(const FText& InText) { if(bIgnoreUIUpdate) { return; } const FString& InputTextStr = InputText->GetText().ToString(); if(!InputTextStr.IsEmpty()) { TArray AutoCompleteList; if (ActiveCommandExecutor) { ActiveCommandExecutor->GetAutoCompleteSuggestions(*InputTextStr, AutoCompleteList); } else { auto OnConsoleVariable = [&AutoCompleteList](const TCHAR *Name, IConsoleObject* CVar) { #if (UE_BUILD_SHIPPING || UE_BUILD_TEST) if (CVar->TestFlags(ECVF_Cheat)) { return; } #endif // (UE_BUILD_SHIPPING || UE_BUILD_TEST) if (CVar->TestFlags(ECVF_Unregistered)) { return; } AutoCompleteList.Add(Name); }; IConsoleManager::Get().ForEachConsoleObjectThatContains(FConsoleObjectVisitor::CreateLambda(OnConsoleVariable), *InputTextStr); } AutoCompleteList.Sort(); SetSuggestions(AutoCompleteList, FText::FromString(InputTextStr)); } else { ClearSuggestions(); } } void SConsoleInputBox::OnTextCommitted( const FText& InText, ETextCommit::Type CommitInfo) { if (CommitInfo == ETextCommit::OnEnter) { if (!InText.IsEmpty()) { // Copy the exec text string out so we can clear the widget's contents. If the exec command spawns // a new window it can cause the text box to lose focus, which will result in this function being // re-entered. We want to make sure the text string is empty on re-entry, so we'll clear it out const FString ExecString = InText.ToString(); // Clear the console input area bIgnoreUIUpdate = true; InputText->SetText(FText::GetEmpty()); ClearSuggestions(); bIgnoreUIUpdate = false; // Exec! if (ConsoleCommandCustomExec.IsBound()) { IConsoleManager::Get().AddConsoleHistoryEntry(TEXT(""), *ExecString); ConsoleCommandCustomExec.Execute(ExecString); } else if (ActiveCommandExecutor) { ActiveCommandExecutor->Exec(*ExecString); } } else { ClearSuggestions(); } OnConsoleCommandExecuted.ExecuteIfBound(); } } FReply SConsoleInputBox::OnPreviewKeyDown(const FGeometry& MyGeometry, const FKeyEvent& KeyEvent) { if(SuggestionBox->IsOpen()) { if(KeyEvent.GetKey() == EKeys::Up || KeyEvent.GetKey() == EKeys::Down) { Suggestions.StepSelectedSuggestion(KeyEvent.GetKey() == EKeys::Up ? -1 : +1); MarkActiveSuggestion(); return FReply::Handled(); } else if (KeyEvent.GetKey() == EKeys::Tab) { if (Suggestions.HasSuggestions()) { if (Suggestions.HasSelectedSuggestion()) { MarkActiveSuggestion(); OnTextCommitted(InputText->GetText(), ETextCommit::OnEnter); } else { Suggestions.SelectedSuggestion = 0; MarkActiveSuggestion(); } } return FReply::Handled(); } else if (KeyEvent.GetKey() == EKeys::Escape) { SuggestionBox->SetIsOpen(false); return FReply::Handled(); } } else { if(KeyEvent.GetKey() == EKeys::Up) { // If the command field isn't empty we need you to have pressed Control+Up to summon the history (to make sure you're not just using caret navigation) const bool bIsMultiLine = GetActiveCommandExecutorAllowMultiLine(); const bool bShowHistory = InputText->GetText().IsEmpty() || KeyEvent.IsControlDown(); if (bShowHistory) { TArray History; if (ActiveCommandExecutor) { ActiveCommandExecutor->GetExecHistory(History); } else { IConsoleManager::Get().GetConsoleHistory(TEXT(""), History); } SetSuggestions(History, FText::GetEmpty()); if(Suggestions.HasSuggestions()) { Suggestions.StepSelectedSuggestion(-1); MarkActiveSuggestion(); } } // Need to always handle this for single-line controls to avoid them invoking widget navigation if (!bIsMultiLine || bShowHistory) { return FReply::Handled(); } } else if (KeyEvent.GetKey() == EKeys::Escape) { if (InputText->GetText().IsEmpty()) { OnCloseConsole.ExecuteIfBound(); } else { // Clear the console input area bIgnoreUIUpdate = true; InputText->SetText(FText::GetEmpty()); bIgnoreUIUpdate = false; ClearSuggestions(); } return FReply::Handled(); } } return FReply::Unhandled(); } void SConsoleInputBox::SetSuggestions(TArray& Elements, FText Highlight) { FString SelectionText; if (Suggestions.HasSelectedSuggestion()) { SelectionText = *Suggestions.GetSelectedSuggestion(); } Suggestions.Reset(); Suggestions.SuggestionsHighlight = Highlight; for(int32 i = 0; i < Elements.Num(); ++i) { Suggestions.SuggestionsList.Add(MakeShared(Elements[i])); if (Elements[i] == SelectionText) { Suggestions.SelectedSuggestion = i; } } SuggestionListView->RequestListRefresh(); if(Suggestions.HasSuggestions()) { // Ideally if the selection box is open the output window is not changing it's window title (flickers) SuggestionBox->SetIsOpen(true, false); if (Suggestions.HasSelectedSuggestion()) { SuggestionListView->RequestScrollIntoView(Suggestions.GetSelectedSuggestion()); } } else { SuggestionBox->SetIsOpen(false); } } void SConsoleInputBox::OnFocusLost( const FFocusEvent& InFocusEvent ) { // SuggestionBox->SetIsOpen(false); } void SConsoleInputBox::MarkActiveSuggestion() { bIgnoreUIUpdate = true; if (Suggestions.HasSelectedSuggestion()) { TSharedPtr SelectedSuggestion = Suggestions.GetSelectedSuggestion(); SuggestionListView->SetSelection(SelectedSuggestion); SuggestionListView->RequestScrollIntoView(SelectedSuggestion); // Ideally this would only scroll if outside of the view InputText->SetText(FText::FromString(*SelectedSuggestion)); } else { SuggestionListView->ClearSelection(); } bIgnoreUIUpdate = false; } void SConsoleInputBox::ClearSuggestions() { SuggestionBox->SetIsOpen(false); Suggestions.Reset(); } void SConsoleInputBox::OnCommandExecutorRegistered(const FName& Type, class IModularFeature* ModularFeature) { if (Type == IConsoleCommandExecutor::ModularFeatureName()) { SyncActiveCommandExecutor(); } } void SConsoleInputBox::OnCommandExecutorUnregistered(const FName& Type, class IModularFeature* ModularFeature) { if (Type == IConsoleCommandExecutor::ModularFeatureName() && ModularFeature == ActiveCommandExecutor) { SyncActiveCommandExecutor(); } } void SConsoleInputBox::SyncActiveCommandExecutor() { TArray CommandExecutors = IModularFeatures::Get().GetModularFeatureImplementations(IConsoleCommandExecutor::ModularFeatureName()); ActiveCommandExecutor = nullptr; if (CommandExecutors.IsValidIndex(0)) { ActiveCommandExecutor = CommandExecutors[0]; } // to swap to a preferred executor, try and match from the active name for (IConsoleCommandExecutor* CommandExecutor : CommandExecutors) { if (CommandExecutor->GetName() == PreferredCommandExecutorName) { ActiveCommandExecutor = CommandExecutor; break; } } } void SConsoleInputBox::SetActiveCommandExecutor(const FName InExecName) { GConfig->SetString(TEXT("OutputLog"), TEXT("PreferredCommandExecutor"), *InExecName.ToString(), GEditorPerProjectIni); PreferredCommandExecutorName = InExecName; SyncActiveCommandExecutor(); } FText SConsoleInputBox::GetActiveCommandExecutorDisplayName() const { if (ActiveCommandExecutor) { return ActiveCommandExecutor->GetDisplayName(); } return FText::GetEmpty(); } FText SConsoleInputBox::GetActiveCommandExecutorHintText() const { if (ActiveCommandExecutor) { return ActiveCommandExecutor->GetHintText(); } return FText::GetEmpty(); } bool SConsoleInputBox::GetActiveCommandExecutorAllowMultiLine() const { if (ActiveCommandExecutor) { return ActiveCommandExecutor->AllowMultiLine(); } return false; } bool SConsoleInputBox::IsCommandExecutorMenuEnabled() const { return !ConsoleCommandCustomExec.IsBound(); // custom execs always show the default executor in the UI (which has the selector disabled) } TSharedRef SConsoleInputBox::GetCommandExecutorMenuContent() { FMenuBuilder MenuBuilder(/*bInShouldCloseWindowAfterMenuSelection=*/true, nullptr); MenuBuilder.BeginSection("CmdExecEntries"); { TArray CommandExecutors = IModularFeatures::Get().GetModularFeatureImplementations(IConsoleCommandExecutor::ModularFeatureName()); CommandExecutors.Sort([](IConsoleCommandExecutor& LHS, IConsoleCommandExecutor& RHS) { return LHS.GetDisplayName().CompareTo(RHS.GetDisplayName()) < 0; }); for (const IConsoleCommandExecutor* CommandExecutor : CommandExecutors) { const bool bIsActiveCmdExec = ActiveCommandExecutor == CommandExecutor; MenuBuilder.AddMenuEntry( CommandExecutor->GetDisplayName(), CommandExecutor->GetDescription(), FSlateIcon(), FUIAction( FExecuteAction::CreateSP(this, &SConsoleInputBox::SetActiveCommandExecutor, CommandExecutor->GetName()), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateLambda([bIsActiveCmdExec] { return bIsActiveCmdExec; }) ), NAME_None, EUserInterfaceActionType::RadioButton ); } } MenuBuilder.EndSection(); return MenuBuilder.MakeWidget(); } FReply SConsoleInputBox::OnKeyDownHandler(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent) { const FInputChord InputChord = FInputChord(InKeyEvent.GetKey(), EModifierKey::FromBools(InKeyEvent.IsControlDown(), InKeyEvent.IsAltDown(), InKeyEvent.IsShiftDown(), InKeyEvent.IsCommandDown())); // Intercept the "open console" key if (ActiveCommandExecutor && (ActiveCommandExecutor->AllowHotKeyClose() && ActiveCommandExecutor->GetHotKey() == InputChord)) { OnCloseConsole.ExecuteIfBound(); return FReply::Handled(); } return FReply::Unhandled(); } FReply SConsoleInputBox::OnKeyCharHandler(const FGeometry& MyGeometry, const FCharacterEvent& InCharacterEvent) { // A printable key may be used to open the console, so consume all characters before our first Tick if (!bHasTicked) { return FReply::Handled(); } FInputChord OpenConsoleChord; if (ActiveCommandExecutor && ActiveCommandExecutor->AllowHotKeyClose()) { OpenConsoleChord = ActiveCommandExecutor->GetHotKey(); const uint32* KeyCode = nullptr; const uint32* CharCode = nullptr; FInputKeyManager::Get().GetCodesFromKey(OpenConsoleChord.Key, KeyCode, CharCode); if (CharCode == nullptr) { return FReply::Unhandled(); } // Intercept the "open console" key if (InCharacterEvent.GetCharacter() == (TCHAR)*CharCode && OpenConsoleChord.NeedsControl() == InCharacterEvent.IsControlDown() && OpenConsoleChord.NeedsAlt() == InCharacterEvent.IsAltDown() && OpenConsoleChord.NeedsShift() == InCharacterEvent.IsShiftDown() && OpenConsoleChord.NeedsCommand() == InCharacterEvent.IsCommandDown()) { return FReply::Handled(); } else { return FReply::Unhandled(); } } else { return FReply::Unhandled(); } } TSharedRef< FOutputLogTextLayoutMarshaller > FOutputLogTextLayoutMarshaller::Create(TArray< TSharedPtr > InMessages, FLogFilter* InFilter) { return MakeShareable(new FOutputLogTextLayoutMarshaller(MoveTemp(InMessages), InFilter)); } FOutputLogTextLayoutMarshaller::~FOutputLogTextLayoutMarshaller() { } void FOutputLogTextLayoutMarshaller::SetText(const FString& SourceString, FTextLayout& TargetTextLayout) { TextLayout = &TargetTextLayout; AppendMessagesToTextLayout(Messages); } void FOutputLogTextLayoutMarshaller::GetText(FString& TargetString, const FTextLayout& SourceTextLayout) { SourceTextLayout.GetAsText(TargetString); } bool FOutputLogTextLayoutMarshaller::AppendMessage(const TCHAR* InText, const ELogVerbosity::Type InVerbosity, const FName& InCategory) { TArray< TSharedPtr > NewMessages; if(SOutputLog::CreateLogMessages(InText, InVerbosity, InCategory, NewMessages)) { const bool bWasEmpty = Messages.Num() == 0; Messages.Append(NewMessages); // Add new message categories to the filter's available log categories for (const auto& NewMessage : NewMessages) { Filter->AddAvailableLogCategory(NewMessage->Category); } if(TextLayout) { // If we were previously empty, then we'd have inserted a dummy empty line into the document // We need to remove this line now as it would cause the message indices to get out-of-sync with the line numbers, which would break auto-scrolling if(bWasEmpty) { TextLayout->ClearLines(); } // If we've already been given a text layout, then append these new messages rather than force a refresh of the entire document AppendMessagesToTextLayout(NewMessages); } else { MarkMessagesCacheAsDirty(); MakeDirty(); } return true; } return false; } void FOutputLogTextLayoutMarshaller::AppendMessageToTextLayout(const TSharedPtr& InMessage) { if (!Filter->IsMessageAllowed(InMessage)) { return; } // Increment the cached count if we're not rebuilding the log if ( !IsDirty() ) { CachedNumMessages++; } const FTextBlockStyle& MessageTextStyle = FEditorStyle::Get().GetWidgetStyle(InMessage->Style); TSharedRef LineText = InMessage->Message; TArray> Runs; Runs.Add(FSlateTextRun::Create(FRunInfo(), LineText, MessageTextStyle)); TextLayout->AddLine(FSlateTextLayout::FNewLineData(MoveTemp(LineText), MoveTemp(Runs))); } void FOutputLogTextLayoutMarshaller::AppendMessagesToTextLayout(const TArray>& InMessages) { TArray LinesToAdd; LinesToAdd.Reserve(InMessages.Num()); int32 NumAddedMessages = 0; for (const auto& CurrentMessage : InMessages) { if (!Filter->IsMessageAllowed(CurrentMessage)) { continue; } ++NumAddedMessages; const FTextBlockStyle& MessageTextStyle = FEditorStyle::Get().GetWidgetStyle(CurrentMessage->Style); TSharedRef LineText = CurrentMessage->Message; TArray> Runs; Runs.Add(FSlateTextRun::Create(FRunInfo(), LineText, MessageTextStyle)); LinesToAdd.Emplace(MoveTemp(LineText), MoveTemp(Runs)); } // Increment the cached message count if the log is not being rebuilt if ( !IsDirty() ) { CachedNumMessages += NumAddedMessages; } TextLayout->AddLines(LinesToAdd); } void FOutputLogTextLayoutMarshaller::ClearMessages() { Messages.Empty(); MakeDirty(); } void FOutputLogTextLayoutMarshaller::CountMessages() { // Do not re-count if not dirty if (!bNumMessagesCacheDirty) { return; } CachedNumMessages = 0; for (const auto& CurrentMessage : Messages) { if (Filter->IsMessageAllowed(CurrentMessage)) { CachedNumMessages++; } } // Cache re-built, remove dirty flag bNumMessagesCacheDirty = false; } int32 FOutputLogTextLayoutMarshaller::GetNumMessages() const { return Messages.Num(); } int32 FOutputLogTextLayoutMarshaller::GetNumFilteredMessages() { // No need to filter the messages if the filter is not set if (!Filter->IsFilterSet()) { return GetNumMessages(); } // Re-count messages if filter changed before we refresh if (bNumMessagesCacheDirty) { CountMessages(); } return CachedNumMessages; } void FOutputLogTextLayoutMarshaller::MarkMessagesCacheAsDirty() { bNumMessagesCacheDirty = true; } FOutputLogTextLayoutMarshaller::FOutputLogTextLayoutMarshaller(TArray< TSharedPtr > InMessages, FLogFilter* InFilter) : Messages(MoveTemp(InMessages)) , CachedNumMessages(0) , Filter(InFilter) , TextLayout(nullptr) { } BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION void SOutputLog::Construct( const FArguments& InArgs ) { // Build list of available log categories from historical logs for (const auto& Message : InArgs._Messages) { Filter.AddAvailableLogCategory(Message->Category); } MessagesTextMarshaller = FOutputLogTextLayoutMarshaller::Create(InArgs._Messages, &Filter); MessagesTextBox = SNew(SMultiLineEditableTextBox) .Style(FEditorStyle::Get(), "Log.TextBox") .TextStyle(FEditorStyle::Get(), "Log.Normal") .ForegroundColor(FLinearColor::Gray) .Marshaller(MessagesTextMarshaller) .IsReadOnly(true) .AlwaysShowScrollbars(true) .OnVScrollBarUserScrolled(this, &SOutputLog::OnUserScrolled) .ContextMenuExtender(this, &SOutputLog::ExtendTextBoxMenu); ChildSlot [ SNew(SBorder) .Padding(3) .BorderImage(FEditorStyle::GetBrush("ToolPanel.GroupBorder")) [ SNew(SVerticalBox) // Output Log Filter +SVerticalBox::Slot() .AutoHeight() .Padding(FMargin(0.0f, 0.0f, 0.0f, 4.0f)) [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .AutoWidth() [ SNew(SComboButton) .ComboButtonStyle(FEditorStyle::Get(), "GenericFilters.ComboButtonStyle") .ForegroundColor(FLinearColor::White) .ContentPadding(0) .ToolTipText(LOCTEXT("AddFilterToolTip", "Add an output log filter.")) .OnGetMenuContent(this, &SOutputLog::MakeAddFilterMenu) .HasDownArrow(true) .ContentPadding(FMargin(1, 0)) .ButtonContent() [ SNew(SHorizontalBox) +SHorizontalBox::Slot() .AutoWidth() [ SNew(STextBlock) .TextStyle(FEditorStyle::Get(), "GenericFilters.TextStyle") .Font(FEditorStyle::Get().GetFontStyle("FontAwesome.9")) .Text(FText::FromString(FString(TEXT("\xf0b0"))) /*fa-filter*/) ] +SHorizontalBox::Slot() .AutoWidth() .Padding(2, 0, 0, 0) [ SNew(STextBlock) .TextStyle(FEditorStyle::Get(), "GenericFilters.TextStyle") .Text(LOCTEXT("Filters", "Filters")) ] ] ] +SHorizontalBox::Slot() .Padding(4, 1, 0, 0) [ SAssignNew(FilterTextBox, SSearchBox) .HintText(LOCTEXT("SearchLogHint", "Search Log")) .OnTextChanged(this, &SOutputLog::OnFilterTextChanged) .OnTextCommitted(this, &SOutputLog::OnFilterTextCommitted) .DelayChangeNotificationsWhileTyping(true) ] ] // Output log area +SVerticalBox::Slot() .FillHeight(1) [ MessagesTextBox.ToSharedRef() ] // The console input box +SVerticalBox::Slot() .AutoHeight() .Padding(FMargin(0.0f, 4.0f, 0.0f, 0.0f)) [ SNew(SBox) .MaxDesiredHeight(180.0f) [ SNew(SConsoleInputBox) .OnConsoleCommandExecuted(this, &SOutputLog::OnConsoleCommandExecuted) // Always place suggestions above the input line for the output log widget .SuggestionListPlacement(MenuPlacement_AboveAnchor) ] ] ] ]; GLog->AddOutputDevice(this); // Remove itself on crash (crashmalloc has limited memory and echoing logs here at that point is useless). FCoreDelegates::OnHandleSystemError.AddRaw(this, &SOutputLog::OnCrash); bIsUserScrolled = false; RequestForceScroll(); } END_SLATE_FUNCTION_BUILD_OPTIMIZATION SOutputLog::~SOutputLog() { if (GLog != nullptr) { GLog->RemoveOutputDevice(this); } FCoreDelegates::OnHandleSystemError.RemoveAll(this); } void SOutputLog::OnCrash() { if (GLog != nullptr) { GLog->RemoveOutputDevice(this); } } bool SOutputLog::CreateLogMessages( const TCHAR* V, ELogVerbosity::Type Verbosity, const class FName& Category, TArray< TSharedPtr >& OutMessages ) { if (Verbosity == ELogVerbosity::SetColor) { // Skip Color Events return false; } else { // Get the style for this message. When piping output from child processes (eg. when cooking through the editor), we want to highlight messages // according to their original verbosity, so also check for "Error:" and "Warning:" substrings. This is consistent with how the build system processes logs. FName Style; if (Category == NAME_Cmd) { Style = FName(TEXT("Log.Command")); } else if (Verbosity == ELogVerbosity::Error || FCString::Stristr(V, TEXT("Error:")) != nullptr) { Style = FName(TEXT("Log.Error")); } else if (Verbosity == ELogVerbosity::Warning || FCString::Stristr(V, TEXT("Warning:")) != nullptr) { Style = FName(TEXT("Log.Warning")); } else { Style = FName(TEXT("Log.Normal")); } // Determine how to format timestamps static ELogTimes::Type LogTimestampMode = ELogTimes::None; if (UObjectInitialized() && !GExitPurge) { // Logging can happen very late during shutdown, even after the UObject system has been torn down, hence the init check above LogTimestampMode = GetDefault()->LogTimestampMode; } const int32 OldNumMessages = OutMessages.Num(); // handle multiline strings by breaking them apart by line TArray LineRanges; FString CurrentLogDump = V; FTextRange::CalculateLineRangesFromString(CurrentLogDump, LineRanges); bool bIsFirstLineInMessage = true; for (const FTextRange& LineRange : LineRanges) { if (!LineRange.IsEmpty()) { FString Line = CurrentLogDump.Mid(LineRange.BeginIndex, LineRange.Len()); Line = Line.ConvertTabsToSpaces(4); // Hard-wrap lines to avoid them being too long static const int32 HardWrapLen = 360; for (int32 CurrentStartIndex = 0; CurrentStartIndex < Line.Len();) { int32 HardWrapLineLen = 0; if (bIsFirstLineInMessage) { FString MessagePrefix = FOutputDeviceHelper::FormatLogLine(Verbosity, Category, nullptr, LogTimestampMode); HardWrapLineLen = FMath::Min(HardWrapLen - MessagePrefix.Len(), Line.Len() - CurrentStartIndex); FString HardWrapLine = Line.Mid(CurrentStartIndex, HardWrapLineLen); OutMessages.Add(MakeShared(MakeShared(MessagePrefix + HardWrapLine), Verbosity, Category, Style)); } else { HardWrapLineLen = FMath::Min(HardWrapLen, Line.Len() - CurrentStartIndex); FString HardWrapLine = Line.Mid(CurrentStartIndex, HardWrapLineLen); OutMessages.Add(MakeShared(MakeShared(MoveTemp(HardWrapLine)), Verbosity, Category, Style)); } bIsFirstLineInMessage = false; CurrentStartIndex += HardWrapLineLen; } } } return OldNumMessages != OutMessages.Num(); } } void SOutputLog::Serialize(const TCHAR* V, ELogVerbosity::Type Verbosity, const class FName& Category) { if ( MessagesTextMarshaller->AppendMessage(V, Verbosity, Category) ) { // Don't scroll to the bottom automatically when the user is scrolling the view or has scrolled it away from the bottom. if( !bIsUserScrolled ) { RequestForceScroll(); } } } void SOutputLog::ExtendTextBoxMenu(FMenuBuilder& Builder) { FUIAction ClearOutputLogAction( FExecuteAction::CreateRaw( this, &SOutputLog::OnClearLog ), FCanExecuteAction::CreateSP( this, &SOutputLog::CanClearLog ) ); Builder.AddMenuEntry( NSLOCTEXT("OutputLog", "ClearLogLabel", "Clear Log"), NSLOCTEXT("OutputLog", "ClearLogTooltip", "Clears all log messages"), FSlateIcon(), ClearOutputLogAction ); } void SOutputLog::OnClearLog() { // Make sure the cursor is back at the start of the log before we clear it MessagesTextBox->GoTo(FTextLocation(0)); MessagesTextMarshaller->ClearMessages(); MessagesTextBox->Refresh(); bIsUserScrolled = false; } void SOutputLog::OnUserScrolled(float ScrollOffset) { bIsUserScrolled = ScrollOffset < 1.0 && !FMath::IsNearlyEqual(ScrollOffset, 1.0f); } bool SOutputLog::CanClearLog() const { return MessagesTextMarshaller->GetNumMessages() > 0; } void SOutputLog::OnConsoleCommandExecuted() { RequestForceScroll(); } void SOutputLog::RequestForceScroll() { if (MessagesTextMarshaller->GetNumFilteredMessages() > 0) { MessagesTextBox->ScrollTo(FTextLocation(MessagesTextMarshaller->GetNumFilteredMessages() - 1)); bIsUserScrolled = false; } } void SOutputLog::Refresh() { // Re-count messages if filter changed before we refresh MessagesTextMarshaller->CountMessages(); MessagesTextBox->GoTo(FTextLocation(0)); MessagesTextMarshaller->MakeDirty(); MessagesTextBox->Refresh(); RequestForceScroll(); } void SOutputLog::OnFilterTextChanged(const FText& InFilterText) { if (Filter.GetFilterText().ToString().Equals(InFilterText.ToString(), ESearchCase::CaseSensitive)) { // nothing to do return; } // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); // Set filter phrases Filter.SetFilterText(InFilterText); // Report possible syntax errors back to the user FilterTextBox->SetError(Filter.GetSyntaxErrors()); // Repopulate the list to show only what has not been filtered out. Refresh(); // Apply the new search text MessagesTextBox->BeginSearch(InFilterText); } void SOutputLog::OnFilterTextCommitted(const FText& InFilterText, ETextCommit::Type InCommitType) { OnFilterTextChanged(InFilterText); } TSharedRef SOutputLog::MakeAddFilterMenu() { FMenuBuilder MenuBuilder(/*bInShouldCloseWindowAfterMenuSelection=*/true, nullptr); MenuBuilder.BeginSection("OutputLogVerbosityEntries", LOCTEXT("OutputLogVerbosityHeading", "Verbosity")); { MenuBuilder.AddMenuEntry( LOCTEXT("ShowMessages", "Messages"), LOCTEXT("ShowMessages_Tooltip", "Filter the Output Log to show messages"), FSlateIcon(), FUIAction(FExecuteAction::CreateSP(this, &SOutputLog::VerbosityLogs_Execute), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateSP(this, &SOutputLog::VerbosityLogs_IsChecked)), NAME_None, EUserInterfaceActionType::ToggleButton ); MenuBuilder.AddMenuEntry( LOCTEXT("ShowWarnings", "Warnings"), LOCTEXT("ShowWarnings_Tooltip", "Filter the Output Log to show warnings"), FSlateIcon(), FUIAction(FExecuteAction::CreateSP(this, &SOutputLog::VerbosityWarnings_Execute), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateSP(this, &SOutputLog::VerbosityWarnings_IsChecked)), NAME_None, EUserInterfaceActionType::ToggleButton ); MenuBuilder.AddMenuEntry( LOCTEXT("ShowErrors", "Errors"), LOCTEXT("ShowErrors_Tooltip", "Filter the Output Log to show errors"), FSlateIcon(), FUIAction(FExecuteAction::CreateSP(this, &SOutputLog::VerbosityErrors_Execute), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateSP(this, &SOutputLog::VerbosityErrors_IsChecked)), NAME_None, EUserInterfaceActionType::ToggleButton ); } MenuBuilder.EndSection(); MenuBuilder.BeginSection("OutputLogMiscEntries", LOCTEXT("OutputLogMiscHeading", "Miscellaneous")); { MenuBuilder.AddSubMenu( LOCTEXT("Categories", "Categories"), LOCTEXT("SelectCategoriesToolTip", "Select Categories to display."), FNewMenuDelegate::CreateSP(this, &SOutputLog::MakeSelectCategoriesSubMenu) ); } return MenuBuilder.MakeWidget(); } void SOutputLog::MakeSelectCategoriesSubMenu(FMenuBuilder& MenuBuilder) { MenuBuilder.BeginSection("OutputLogCategoriesEntries"); { MenuBuilder.AddMenuEntry( LOCTEXT("ShowAllCategories", "Show All"), LOCTEXT("ShowAllCategories_Tooltip", "Filter the Output Log to show all categories"), FSlateIcon(), FUIAction(FExecuteAction::CreateSP(this, &SOutputLog::CategoriesShowAll_Execute), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateSP(this, &SOutputLog::CategoriesShowAll_IsChecked)), NAME_None, EUserInterfaceActionType::ToggleButton ); for (const FName Category : Filter.GetAvailableLogCategories()) { MenuBuilder.AddMenuEntry( FText::AsCultureInvariant(Category.ToString()), FText::Format(LOCTEXT("Category_Tooltip", "Filter the Output Log to show category: {0}"), FText::AsCultureInvariant(Category.ToString())), FSlateIcon(), FUIAction(FExecuteAction::CreateSP(this, &SOutputLog::CategoriesSingle_Execute, Category), FCanExecuteAction::CreateLambda([] { return true; }), FIsActionChecked::CreateSP(this, &SOutputLog::CategoriesSingle_IsChecked, Category)), NAME_None, EUserInterfaceActionType::ToggleButton ); } } MenuBuilder.EndSection(); } bool SOutputLog::VerbosityLogs_IsChecked() const { return Filter.bShowLogs; } bool SOutputLog::VerbosityWarnings_IsChecked() const { return Filter.bShowWarnings; } bool SOutputLog::VerbosityErrors_IsChecked() const { return Filter.bShowErrors; } void SOutputLog::VerbosityLogs_Execute() { Filter.bShowLogs = !Filter.bShowLogs; // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); Refresh(); } void SOutputLog::VerbosityWarnings_Execute() { Filter.bShowWarnings = !Filter.bShowWarnings; // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); Refresh(); } void SOutputLog::VerbosityErrors_Execute() { Filter.bShowErrors = !Filter.bShowErrors; // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); Refresh(); } bool SOutputLog::CategoriesShowAll_IsChecked() const { return Filter.bShowAllCategories; } bool SOutputLog::CategoriesSingle_IsChecked(FName InName) const { return Filter.IsLogCategoryEnabled(InName); } void SOutputLog::CategoriesShowAll_Execute() { Filter.bShowAllCategories = !Filter.bShowAllCategories; Filter.ClearSelectedLogCategories(); if (Filter.bShowAllCategories) { for (const auto& AvailableCategory : Filter.GetAvailableLogCategories()) { Filter.ToggleLogCategory(AvailableCategory); } } // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); Refresh(); } void SOutputLog::CategoriesSingle_Execute(FName InName) { Filter.ToggleLogCategory(InName); // Flag the messages count as dirty MessagesTextMarshaller->MarkMessagesCacheAsDirty(); Refresh(); } bool FLogFilter::IsMessageAllowed(const TSharedPtr& Message) { // Filter Verbosity { if (Message->Verbosity == ELogVerbosity::Error && !bShowErrors) { return false; } if (Message->Verbosity == ELogVerbosity::Warning && !bShowWarnings) { return false; } if (Message->Verbosity != ELogVerbosity::Error && Message->Verbosity != ELogVerbosity::Warning && !bShowLogs) { return false; } } // Filter by Category { if (!IsLogCategoryEnabled(Message->Category)) { return false; } } // Filter search phrase { if (!TextFilterExpressionEvaluator.TestTextFilter(FLogFilter_TextFilterExpressionContext(*Message))) { return false; } } return true; } void FLogFilter::AddAvailableLogCategory(FName& LogCategory) { // Use an insert-sort to keep AvailableLogCategories alphabetically sorted int32 InsertIndex = 0; for (InsertIndex = AvailableLogCategories.Num()-1; InsertIndex >= 0; --InsertIndex) { FName CheckCategory = AvailableLogCategories[InsertIndex]; // No duplicates if (CheckCategory == LogCategory) { return; } else if (CheckCategory.Compare(LogCategory) < 0) { break; } } AvailableLogCategories.Insert(LogCategory, InsertIndex+1); if (bShowAllCategories) { ToggleLogCategory(LogCategory); } } void FLogFilter::ToggleLogCategory(const FName& LogCategory) { int32 FoundIndex = SelectedLogCategories.Find(LogCategory); if (FoundIndex == INDEX_NONE) { SelectedLogCategories.Add(LogCategory); } else { SelectedLogCategories.RemoveAt(FoundIndex, /*Count=*/1, /*bAllowShrinking=*/false); } } bool FLogFilter::IsLogCategoryEnabled(const FName& LogCategory) const { return SelectedLogCategories.Contains(LogCategory); } void FLogFilter::ClearSelectedLogCategories() { // No need to churn memory each time the selected categories are cleared SelectedLogCategories.Reset(SelectedLogCategories.GetAllocatedSize()); } #undef LOCTEXT_NAMESPACE