It’s finally time for me to learn the python side of Unreal Engine, so naturally to get started I have to write a custom Python Script Editor…
Honestly it baffles me that Epic haven’t added one, leaving developers with just a plain text command line input.
I guess python isn’t particularly high on their priority lists.

I’ll be keeping this editor very simple, partly because it’s really just for learning and diagnostics, and also because I am still new to slate and some features are beyond my current skill level for now.

Fortunately this isn’t my first python editor, I’ve made one each for Blender and Maya, the later with a whole suite of Qt -based object introspection tools I’ll try to bring to slate at a later date.

Note that this is not a tutorial, just an overview of my development process.

As always, I split my requirements into “Minimal” and “Production-Ready” to ensure I don’t get off track.

Minimal Requirements:

  • Syntax Highlighting
  • Auto Indentation
  • Can Execute Python Code
  • Hotkeys for help and execution
  • Auto-Complete
  • Adjustable font size (default font is so small!)

Production-Ready Requirements

  • Documentation quick links
  • Exception introspection
  • Object state viewer
  • Common Snippets Menu
  • Line Numbers
  • Multiple Tabs
  • Open/Save support
  • Save current state on exit/crash

Implementation:

Conveniently, Epic have made an experimental code editor plugin with syntax highlighting we can use as a base, Take a look over at: Engine/Plugins/Experimental/CodeEditor/Source/CodeEditor.

While not a large project, there is still a fair amount of code, so I will cover what I feel are the important topics of this, then will leave a link to the source at the end for you to pull apart further.

I will admit I’m not entirely happy with my communication between the EditorPanel and PythonTextEditor and may refactor that at a later date.

Structure:

Class Layout

Custom SMultiLineEditableText

At the core of the editor is a custom SMultiLineEditableText, this is Slates equivalent of the QTextEdit.
There are a few things this has to accomplish:

  • Syntax Highlighting
  • Track caret position for completer popup
  • Sending selected code for evaluation/completion
  • Adding code at cursor position
  • Tracking user hotkey presses

Let’s start with tracking the caret position, If you think you can just access the current position you would be wrong, it’s a private variable so we need to either expose it in engine or track it ourselves…
Fortunately there is an OnCursorMoved event we can override

class SPythonTextEditor : public SMultiLineEditableText
{
public:
    FTextLocation GetTextLocation() const {
        return CaretLocation;
    }
protected:
    virtual void OnCursorMoved(const FTextLocation& InLocation) override {
        CaretLocation = InLocation;
    }
private:
    FTextLocation CaretLocation;
}

Next let’s get the events out of the way, SLATE_EVENT is of course the equivalent of Qt Slots.
For events requiring parameters we need to declare a corresponding delegate. There are some common built in ones but I find it clearer to be explicit in this.
Here are all the events used, The caveat of slate is we need to include all the inherited args for some reason.

class SPythonTextEditor : public SMultiLineEditableText
{
public:
	DECLARE_DELEGATE_TwoParams(FOnExecuteTriggered, const FString& /*Text*/, bool /*LogOutput*/);
	DECLARE_DELEGATE_OneParam(FOnDocumentationRequested, const FString& /*Text*/);

	SLATE_BEGIN_ARGS(SPythonTextEditor) {}
		/** The initial text that will appear in the widget. */
		SLATE_ATTRIBUTE(FText, Text)

		/** The marshaller used to get/set the raw text to/from the text layout. */
		SLATE_ARGUMENT(TSharedPtr< ITextLayoutMarshaller >, Marshaller)

		/** The horizontal scroll bar widget */
		SLATE_ARGUMENT(TSharedPtr< SScrollBar >, HScrollBar)

		/** The vertical scroll bar widget */
		SLATE_ARGUMENT(TSharedPtr< SScrollBar >, VScrollBar)

		/** Called whenever the text is changed interactively by the user */
		SLATE_EVENT(FOnTextChanged, OnTextChanged)

		/** Emitted when Ctrl+Space is pressed */
		SLATE_EVENT(FSimpleDelegate, OnAutoCompleteRequested)

		/** Emitted when Enter or Right is pressed with completer shown */
		SLATE_EVENT(FSimpleDelegate, OnHideAutoCompleteRequested)
		
		/** Emitted when Enter is pressed with completer shown */
		SLATE_EVENT(FSimpleDelegate, OnAcceptCompleter)

		/** Emitted when Up arrow is pressed with completer shown */
		SLATE_EVENT(FSimpleDelegate, OnCompleterNavUpRequested)

		/** Emitted when Down arrow is pressed with completer shown */
		SLATE_EVENT(FSimpleDelegate, OnCompleterNavDownRequested)

		/** Emitted when Ctrl+Enter is pressed */
		SLATE_EVENT(FOnExecuteTriggered, OnExecuteTriggered)

		/** Emitted when Documentation menu item is pressed */
		SLATE_EVENT(FOnDocumentationRequested, OnDocumentationRequested)
	SLATE_END_ARGS()

private:
	FOnExecuteTriggered OnExecuteTriggered;
	FOnDocumentationRequested OnDocumentationRequested;
	FSimpleDelegate OnAutoCompleteRequested;
	FSimpleDelegate OnHideAutoCompleteRequested;
	FSimpleDelegate OnAcceptCompleter;
	FSimpleDelegate OnCompleterNavUpRequested;
	FSimpleDelegate OnCompleterNavDownRequested;

};

Custom Context Menu

Context menus are easy so I’ll get this out of the way. I’ve chosen to copy/paste the BuildContextMenuContent contents from the underlying EditableTextLayout, this gives me control over ordering and does highlight how useful slates menu callbacks are compared to Qt. As the callbacks are already defined I can just copy the structure.

void SPythonTextEditor::Construct(const FArguments& Args)
{
	UICommandList = MakeShareable(new FUICommandList());

	UICommandList->MapAction(FScriptEditorCommands::Get().Help,
		FExecuteAction::CreateRaw(this, &SPythonTextEditor::PrintHelp)
		);
	UICommandList->MapAction(FScriptEditorCommands::Get().OpenDocumentation,
		FExecuteAction::CreateRaw(this, &SPythonTextEditor::OpenDocumentation)
		);
}
TSharedPtr<SWidget> SPythonTextEditor::BuildContextMenuContent() const
{
	// Set the menu to automatically close when the user commits to a choice
	const bool bShouldCloseWindowAfterMenuSelection = true;
	
	// This is a context menu which could be summoned from within another menu if this text block is in a menu
	// it should not close the menu it is inside
	bool bCloseSelfOnly = true;

	FMenuBuilder MenuBuilder(bShouldCloseWindowAfterMenuSelection, UICommandList, MenuExtender, bCloseSelfOnly, &FCoreStyle::Get());
	{
		MenuBuilder.BeginSection("Help");
		{
			MenuBuilder.AddMenuEntry(FScriptEditorCommands::Get().Help);
			MenuBuilder.AddMenuEntry(FScriptEditorCommands::Get().OpenDocumentation);
		}
		MenuBuilder.EndSection();
        // Below copied from EditabltTextLayout
		MenuBuilder.BeginSection("EditText", LOCTEXT("Heading", "Modify Text"));
		{
			// Undo
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().Undo);
		}
		MenuBuilder.EndSection();

		MenuBuilder.BeginSection("EditableTextModify2");
		{
			// Cut
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().Cut);

			// Copy
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().Copy);

			// Paste
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().Paste);

			// Delete
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().Delete);
		}
		MenuBuilder.EndSection();

		MenuBuilder.BeginSection("EditableTextModify3");
		{
			// Select All
			MenuBuilder.AddMenuEntry(FGenericCommands::Get().SelectAll);
		}
		MenuBuilder.EndSection();
	}

	return MenuBuilder.MakeWidget();
}

Help Callbacks

I’ll cover how the panel deals with python code shortly, but this shows how I am sending “command requests” from the text editor back up the stack to the panel

void SPythonTextEditor::PrintHelp() const
{
	if (OnExecuteTriggered.IsBound()) {
		if ( AnyTextSelected() )
			OnExecuteTriggered.Execute(FText::Format(LOCTEXT("HelpCmd", "help({0})"), {GetSelectedText()}).ToString(), false);
		else
			OnExecuteTriggered.Execute(LOCTEXT("HelpCmd", "print('No Text Selected')").ToString(), false);
	}
}
void SPythonTextEditor::OpenDocumentation() const
{
	if (OnDocumentationRequested.IsBound()) {
		if ( AnyTextSelected() ) {
			OnDocumentationRequested.Execute(GetSelectedText().ToString());
		}
	}
}

Hotkeys

Specifically for the SMultiLineTextEdit there are two places to implement hotkeys, first being OnKeyChar, we use this so the key pressed isn’t actually typed into the editor when we want to use it as a hotkey (ctrl+H for help should not type an H)
It is important to note here that the character key may or may not change depending if a modifier is on.

FReply SPythonTextEditor::OnKeyChar( const FGeometry& MyGeometry,const FCharacterEvent& InCharacterEvent )
{
	// Check for special characters
	const TCHAR Character = InCharacterEvent.GetCharacter();
	switch (Character)
	{
	case TCHAR(10):		// Enter
	case TCHAR(13):		// Ctrl+Enter
	case TCHAR(8):		// Ctrl+H
	case TCHAR(104):	// H
	case TCHAR(112):	// P
	case TCHAR(32):		// Space
		if ( InCharacterEvent.GetModifierKeys().IsControlDown() )
			return FReply::Handled();
		break;
	default:
		break;
	}
	// Uncomment this if you want to display the key character code being pressed
	// UE_LOG(LogTemp, Display, TEXT("KEY: %d"), Character );
	return Super::OnKeyChar(MyGeometry, InCharacterEvent);
}

My hotkey OnKeyDown handler is a monstrosity for now but the jist of it is:
We check if the pressed key matches the hotkey (ctrl+enter)m then we check if the event is bound and if so we execute the hotkey.
It’s important to remember to return FReply::Handled otherwise Slate will continue doing it’s own thing.

FReply SPythonTextEditor::OnKeyDown( const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent )
{
	FReply Reply = FReply::Unhandled();

	if ( InKeyEvent.GetKey() == EKeys::Enter && InKeyEvent.IsControlDown() ) {
		// Ctrl+Enter
		if (OnExecuteTriggered.IsBound()) {
			if ( AnyTextSelected() )
				OnExecuteTriggered.Execute(GetSelectedText().ToString(), true);
			else
				OnExecuteTriggered.Execute(GetText().ToString(), true);
		}
		Reply = FReply::Handled();
	} else if ( InKeyEvent.GetKey() == EKeys::Enter && InKeyEvent.IsAltDown() ) {
		// Alt+Enter
		Reply = FReply::Handled();
	} else if ( InKeyEvent.GetKey() == EKeys::H && InKeyEvent.IsControlDown() ) {
		// Ctrl+H
		PrintHelp();
		Reply = FReply::Handled();
	} else if ( InKeyEvent.GetKey() == EKeys::P && InKeyEvent.IsControlDown() ) {
		// Ctrl+P
		if (OnExecuteTriggered.IsBound()) {
			if ( AnyTextSelected() )
				OnExecuteTriggered.Execute(FText::Format(LOCTEXT("PPrintCmd", "import pprint;pprint.pprint({0})"), {GetSelectedText()}).ToString(), false);
		}
		Reply = FReply::Handled();
	} else {
		Reply = Super::OnKeyDown( MyGeometry, InKeyEvent );
	}
	return Reply;
}

Auto Indentation

Pressing enter followed by several spaces gets tedious. As does pressing Tab only to be shifted to another editor.
So it’s time to register “tab” and “enter” as a hotkey.
For this we need 3 methods for indent, unindent and how much indent do we have:

void SPythonTextEditor::IndentLine( uint32 LineNumber ) {
	GoTo(FTextLocation(LineNumber, 0));
	InsertTextAtCursor("    ");
}

int32 SPythonTextEditor::UnIndentLine( uint32 LineNumber, const FString& Line ) {
	GoTo(FTextLocation(LineNumber, 0));
	FString Whitespace = GetPreceedingWhitespace( Line );
	int32 ToRemove = FGenericPlatformMath::Min( Whitespace.Len(), int32(4) );
	for ( int i=0;i<ToRemove;++i ) {
		EditableTextLayout->HandleDelete();
	}
	return ToRemove;
}
FString SPythonTextEditor::GetPreceedingWhitespace( const FString& Line ) const
{
	const FRegexPattern Pattern(TEXT("^([ ]+).*$"));
	FRegexMatcher Matcher(Pattern, Line);
	if ( Matcher.FindNext() )
		return Matcher.GetCaptureGroup( 1 );
	return FString();
}

Maintaining indentation is as easy as getting the current indentation, and if the line ends with a colon “:” we add four spaces.

FReply SPythonTextEditor::OnKeyDown( const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent )
{
	//...
	} else if ( InKeyEvent.GetKey() == EKeys::Enter ) {
		if ( bCompleterIsShown && OnAcceptCompleter.IsBound() ) {
			OnAcceptCompleter.Execute();
		} else {
			FString CurrentLine;
			GetCurrentTextLine( CurrentLine );
			FString Whitespace = GetPreceedingWhitespace( CurrentLine );
			if ( CurrentLine.EndsWith(":") )
				Whitespace.InsertAt(0, "    ");
			Whitespace.InsertAt( 0, "\n" );
			InsertTextAtCursor( Whitespace );
		}
		Reply = FReply::Handled();

Tab is a little more… tedious.
If we have nothing selected, add/remove indent from start of the line.
If we have code selected then add/remove indent from start of all lines.
If a line has no more space to unindent then skip it and continue unindenting the rest
Then try to restore the caret position and selected text. (which unreal does not support)

	} else if ( InKeyEvent.GetKey() == EKeys::Tab ) {
		if ( !GetText().IsEmpty() ) {
			FTextLocation StoredLocation = CaretLocation;
			FString CurrentLine;
			GetCurrentTextLine( CurrentLine );
			if ( AnyTextSelected() ) {
				FString String = GetSelectedText().ToString();
				TArray<FString> SelectedLines;
				uint32 NumLines = String.ParseIntoArrayLines( SelectedLines, false );

				if ( SelectedLines.Num() == 1 ) {
					// Only one line
					GoTo(FTextLocation(StoredLocation.GetLineIndex(), 0));
					if ( InKeyEvent.IsShiftDown() ) {
						TArray<FString> AllLines;
						GetText().ToString().ParseIntoArrayLines( AllLines, false );
						FString Line = AllLines[StoredLocation.GetLineIndex()];
						int32 Removed = UnIndentLine( StoredLocation.GetLineIndex(), Line );
						GoTo(FTextLocation(StoredLocation.GetLineIndex(), StoredLocation.GetOffset() - Removed));

					} else {
						IndentLine( StoredLocation.GetLineIndex() );
						GoTo(FTextLocation(StoredLocation.GetLineIndex(), StoredLocation.GetOffset() + 4));
					}
					// Restore vertical scroll?
				} else {
					TArray<FString> AllLines;
					GetText().ToString().ParseIntoArrayLines( AllLines, false );
					uint32 Start;
					uint32 End;
					if ( StoredLocation.GetLineIndex() - SelectedLines.Num() < 0 ) {
						// Selecting Down
						Start = StoredLocation.GetLineIndex();
						End = Start + SelectedLines.Num() - 1;
					} else {
						// Selecting Up
						End = StoredLocation.GetLineIndex();
						Start = End - SelectedLines.Num() + 1;
					}
					// Double check selection order
					for ( uint32 i=Start;i<=End;++i ) {
						if ( i > (uint32)AllLines.Num() ) {
						} else if (AllLines[i] != SelectedLines[i-Start]) {
							auto& Line = SelectedLines[i-Start];
							if ( (i-Start) == 0 && (Line.Len() == 0 || AllLines[i].EndsWith(Line)) )
								continue;
							if ( i-Start == SelectedLines.Num() && (Line.Len() == 0 || AllLines[i].StartsWith(Line)) )
								continue;
							uint32 Temp = Start;
							Start = End;
							End = Temp;
							break;
						}
					}
					int32 Change = 4;
					if ( InKeyEvent.IsShiftDown() ) {
						for ( uint32 i=Start;i<=End;++i ) {
							int32 LineChange = UnIndentLine( i, AllLines[i] );
							if ( i == StoredLocation.GetLineIndex() )
								Change = LineChange;
						}
					} else {
						for ( uint32 i=Start;i<=End;++i )
							IndentLine( i );
					}
					GoTo(FTextLocation(StoredLocation.GetLineIndex(), StoredLocation.GetOffset() + Change));
				}
			} else {
				// Only one line
				GoTo(FTextLocation(StoredLocation.GetLineIndex(), 0));
				if ( InKeyEvent.IsShiftDown() ) {
					TArray<FString> AllLines;
					GetText().ToString().ParseIntoArrayLines( AllLines, false );
					FString Line = AllLines[StoredLocation.GetLineIndex()];
					int32 Removed = UnIndentLine( StoredLocation.GetLineIndex(), Line );
					GoTo(FTextLocation(StoredLocation.GetLineIndex(), StoredLocation.GetOffset() - Removed));

				} else {
					IndentLine( StoredLocation.GetLineIndex() );
					GoTo(FTextLocation(StoredLocation.GetLineIndex(), StoredLocation.GetOffset() + 4));
				}
			}
			// Currently unreal doesn't expose code to set selection :/
			ClearSelection();
		}
		Reply = FReply::Handled();
	}

Execute Selected Code

This is done with a big button, but the hotkey is more convenient for partial execution such as “what is this variable” or “run this line”

	} else if ( InKeyEvent.GetKey() == EKeys::Enter ) {
		if ( bCompleterIsShown && OnAcceptCompleter.IsBound() ) {
			OnAcceptCompleter.Execute();
		} else {
			FString CurrentLine;
			GetCurrentTextLine( CurrentLine );
			FString Whitespace = GetPreceedingWhitespace( CurrentLine );
			if ( CurrentLine.EndsWith(":") )
				Whitespace.InsertAt(0, "    ");
			Whitespace.InsertAt( 0, "\n" );
			InsertTextAtCursor( Whitespace );
		}
		Reply = FReply::Handled();
	}

Setting the Font Size

Unreal conveniently lets you adjust application wide font, but for a script editor or output log where you get a wall of text sometimes you want that to be a little bigger.
We’ll handle this with ctrl+ +- and ctrl+mouseWheel.
Most of this is handled in the text marshaller which we’ll cover shortly.

// Hotkeys
	} else if ( InKeyEvent.GetKey() == EKeys::Add && InKeyEvent.IsControlDown() ) {
		RichTextMarshaller->SetFontSize( RichTextMarshaller->GetFontSize() * 1.2 );
		EditableTextLayout->SetTextStyle( RichTextMarshaller->GetTextStyle() );
	} else if ( InKeyEvent.GetKey() == EKeys::Subtract && InKeyEvent.IsControlDown() ) {
		RichTextMarshaller->SetFontSize( RichTextMarshaller->GetFontSize() / 1.2 );
		EditableTextLayout->SetTextStyle( RichTextMarshaller->GetTextStyle() );
	}
// Mouse Wheel
FReply SPythonTextEditor::OnMouseWheel( const FGeometry& MyGeometry,const FPointerEvent& InMouseEvent )
{
	FReply Reply = FReply::Unhandled();
	if ( InMouseEvent.IsControlDown() && InMouseEvent.GetWheelDelta() > 0 ) {
		RichTextMarshaller->SetFontSize( RichTextMarshaller->GetFontSize() * 1.2 );
		EditableTextLayout->SetTextStyle( RichTextMarshaller->GetTextStyle() );

		Reply = FReply::Handled();
	}
	else if ( InMouseEvent.IsControlDown() && InMouseEvent.GetWheelDelta() < 0 ) {
		RichTextMarshaller->SetFontSize( RichTextMarshaller->GetFontSize() / 1.2 );
		EditableTextLayout->SetTextStyle( RichTextMarshaller->GetTextStyle() );
	}
	else {
		Reply = Super::OnMouseWheel( MyGeometry, InMouseEvent );
	}
	return Reply;
}

Syntax highlighting with a Marshaller

Starting with the CPPRichTextSyntaxHighlighterTextLayoutMarshaller.h provided by Epic All I had to do was tweak the keyword list, supply the get/set font size and fix up the keyword matcher which was incorrect.
Essentially a marshaller works by registering a set of tokens to look for and stepping through them to apply formatting.
Personally I would do this differently as I prefer regular expressions over tokens but hey, it works.
You can view the full file in the source linked below but this is the jist of it:

void FPythonSyntaxMarshaller::ParseTokens(const FString& SourceString, FTextLayout& TargetTextLayout, TArray<FSyntaxTokenizer::FTokenizedLine> TokenizedLines)
{
	enum class EParseState : uint8
	{
		None,
		LookingForDoubleQuoteString,
		LookingForSingleQuoteString,
		LookingForSingleLineComment,
		LookingForMultiLineComment,
	};
	TArray<FTextLayout::FNewLineData> LinesToAdd;
	LinesToAdd.Reserve(TokenizedLines.Num());

	// Parse the tokens, generating the styled runs for each line
	EParseState ParseState = EParseState::None;
	for(const FSyntaxTokenizer::FTokenizedLine& TokenizedLine : TokenizedLines) {
		TSharedRef<FString> ModelString = MakeShareable(new FString());
		TArray< TSharedRef< IRun > > Runs;

		if(ParseState == EParseState::LookingForSingleLineComment)
			ParseState = EParseState::None;

		for(const FSyntaxTokenizer::FToken& Token : TokenizedLine.Tokens) {
			const FString TokenText = SourceString.Mid(Token.Range.BeginIndex, Token.Range.Len());
			const FTextRange ModelRange(ModelString->Len(), ModelString->Len() + TokenText.Len());
			ModelString->Append(TokenText);

			FRunInfo RunInfo(TEXT("SyntaxHighlight.PY.Normal"));
			FTextBlockStyle TextBlockStyle = SyntaxTextStyle.NormalTextStyle;

			const bool bIsWhitespace = FString(TokenText).TrimEnd().IsEmpty();
			if(!bIsWhitespace) {
				bool bHasMatchedSyntax = false;
				if(Token.Type == FSyntaxTokenizer::ETokenType::Syntax) {
					if(ParseState == EParseState::None && TokenText == TEXT("\"")) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.String");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
						ParseState = EParseState::LookingForDoubleQuoteString;
						bHasMatchedSyntax = true;
					} else if(ParseState == EParseState::LookingForDoubleQuoteString && TokenText == TEXT("\"")) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Normal");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
						ParseState = EParseState::None;
					} else if(ParseState == EParseState::None && TokenText == TEXT("\'")) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.String");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
						ParseState = EParseState::LookingForSingleQuoteString;
						bHasMatchedSyntax = true;
					} else if(ParseState == EParseState::LookingForSingleQuoteString && TokenText == TEXT("\'")) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Normal");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
						ParseState = EParseState::None;
					} else if(ParseState == EParseState::None && TokenText == TEXT("#")) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Comment");
						TextBlockStyle = SyntaxTextStyle.CommentTextStyle;
						ParseState = EParseState::LookingForSingleLineComment;
					} else if(ParseState == EParseState::None && (TokenText == TEXT("\"\"\"") || TokenText == TEXT("'''"))) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Comment");
						TextBlockStyle = SyntaxTextStyle.CommentTextStyle;
						ParseState = EParseState::LookingForMultiLineComment;
					} else if(ParseState == EParseState::LookingForMultiLineComment && (TokenText == TEXT("\"\"\"") || TokenText == TEXT("'''"))) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Comment");
						TextBlockStyle = SyntaxTextStyle.CommentTextStyle;
						ParseState = EParseState::None;
					}else if(ParseState == EParseState::None && TChar<WIDECHAR>::IsAlpha(TokenText[0])) {
						// [space or line-start][not alphanumeric][TokenText][any][not alphanumeric][space, color or line-end]
						FString PatternString = FString::Format(TEXT("(^|\\s|^[\\._a-zA-Z0-9]){0}(^[_a-zA-Z0-9]|\\s|:|$)"), { TokenText });
						// Expand range by 1 to check if this is a whole word match
						FString Extended = SourceString.Mid(Token.Range.BeginIndex - 1, TokenText.Len() + (Token.Range.BeginIndex > 0 ? 2 : 1));
						FRegexPattern Pattern(PatternString);
						FRegexMatcher Matcher(Pattern, Extended);
						if ( Matcher.FindNext() ) {
							RunInfo.Name = TEXT("SyntaxHighlight.PY.Keyword");
							TextBlockStyle = SyntaxTextStyle.KeywordTextStyle;
							ParseState = EParseState::None;
						}
					} else if(ParseState == EParseState::None && !TChar<WIDECHAR>::IsAlpha(TokenText[0])) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Operator");
						TextBlockStyle = SyntaxTextStyle.OperatorTextStyle;
						ParseState = EParseState::None;
					}
				}
				
				// It's possible that we fail to match a syntax token if we're in a state where it isn't parsed
				// In this case, we treat it as a literal token
				if(Token.Type == FSyntaxTokenizer::ETokenType::Literal || !bHasMatchedSyntax) {
					if(ParseState == EParseState::LookingForDoubleQuoteString) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.String");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
					} else if(ParseState == EParseState::LookingForSingleQuoteString) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.String");
						TextBlockStyle = SyntaxTextStyle.StringTextStyle;
					} else if(ParseState == EParseState::LookingForSingleLineComment) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Comment");
						TextBlockStyle = SyntaxTextStyle.CommentTextStyle;
					} else if(ParseState == EParseState::LookingForMultiLineComment) {
						RunInfo.Name = TEXT("SyntaxHighlight.PY.Comment");
						TextBlockStyle = SyntaxTextStyle.CommentTextStyle;
					}
				}

				TSharedRef< ISlateRun > Run = FSlateTextRun::Create(RunInfo, ModelString, TextBlockStyle, ModelRange);
				Runs.Add(Run);
			} else {
				RunInfo.Name = TEXT("SyntaxHighlight.PY.WhiteSpace");
				TSharedRef< ISlateRun > Run = FWhiteSpaceTextRun::Create(RunInfo, ModelString, TextBlockStyle, ModelRange, 4);
				Runs.Add(Run);
			}
		}

		LinesToAdd.Emplace(MoveTemp(ModelString), MoveTemp(Runs));
	}

	TargetTextLayout.AddLines(LinesToAdd);
}

FPythonSyntaxMarshaller::FPythonSyntaxMarshaller(TSharedPtr< FSyntaxTokenizer > InTokenizer, const FSyntaxTextStyle& InSyntaxTextStyle)
	: FSyntaxHighlighterTextLayoutMarshaller(MoveTemp(InTokenizer))
	, SyntaxTextStyle(InSyntaxTextStyle)
{
}

int32 FPythonSyntaxMarshaller::GetFontSize() const
{
	return SyntaxTextStyle.NormalTextStyle.Font.Size;
}
void FPythonSyntaxMarshaller::SetFontSize( int32 Size )
{
	if ( Size < 6 || Size > 108 ) return;
	SyntaxTextStyle.NormalTextStyle.Font.Size = Size;;
	SyntaxTextStyle.OperatorTextStyle.Font.Size = Size;;
	SyntaxTextStyle.KeywordTextStyle.Font.Size = Size;;
	SyntaxTextStyle.StringTextStyle.Font.Size = Size;;
	SyntaxTextStyle.NumberTextStyle.Font.Size = Size;;
	SyntaxTextStyle.CommentTextStyle.Font.Size = Size;
	MakeDirty();
}

Executing Python Code

Python code is called via the IPythonScriptPlugin, which has sadly been designed around executing single lines of python and not much else, I’d like to see Epic put a little more effort into exposing manipulation of python objects so we can do object introspection but for now this is ok.

void SScriptEditorPanel::ExecutePython( const FString& Text, bool LogCommand ) const
{
	IPythonScriptPlugin* PythonPlugin = IPythonScriptPlugin::Get();
	if ( !PythonPlugin || !PythonPlugin->IsPythonAvailable() ) {
    	UE_LOG(LogTemp, Error, TEXT("Python Plugin not loaded!"));
	} else {
		if ( LogCommand ) {
			UE_LOG(LogTemp, Display, TEXT("Code:\n%s"), *Text);

			if ( Text.Contains(TEXT("\n"))) {
				if ( !PythonPlugin->ExecPythonCommand(*Text) ) {
					UE_LOG(LogTemp, Error, TEXT("Python Execution Failed!"));
				}
			} else {
				FPythonCommandEx Ex;
				Ex.ExecutionMode = EPythonCommandExecutionMode::ExecuteStatement;
				Ex.Command = Text;
				if ( !PythonPlugin->ExecPythonCommandEx(Ex) ) {
					UE_LOG(LogTemp, Error, TEXT("Python Execution Failed!"));
				}
			}
		} else {
			if ( !PythonPlugin->ExecPythonCommand(*Text) ) {
				UE_LOG(LogTemp, Error, TEXT("Python Execution Failed!"));
			}
		}
		
	}
}

Jumping to Documentation

I’ll admit I am still new to unreal and constantly need to reference the docs. The ctrl+H to print docstring is useful but i want to have a shortcut to open the documentation in the webbrowser. This is my solution:
In my Construct I register a python function to inspect the passed object and find it’s documentation page:

	// Setup python helpers
	ExecutePython(LOCTEXT("SScriptEditorPanel_OpenDocumentation", "def SScriptEditorPanel_OpenDocumentation(obj):\n\
    import inspect, unreal, webbrowser\n\
    if hasattr(obj, '__self__'):\n\
        cls = obj.__self__.__name__\n\
        method = obj.__name__\n\
    else:\n\
        cls = obj.__name__\n\
        method = None\n\
    if cls not in dir(unreal):\n\
        print('{} not from unreal'.format(cls))\n\
        return\n\
    html = 'https://docs.unrealengine.com/5.0/en-US/PythonAPI/class/{0}.html#unreal.{0}'.format(cls)\n\
    if method:\n\
        html += '.{}'.format(method)\n\
    print('Opening: {}'.format(html))\n\
    webbrowser.open(html)").ToString(), false);

This is then called from the editor menu callback event:

void SScriptEditorPanel::OpenDocumentation( const FString& Text ) const
{
	IPythonScriptPlugin* PythonPlugin = IPythonScriptPlugin::Get();
	if ( !PythonPlugin || !PythonPlugin->IsPythonAvailable() ) {
    	UE_LOG(LogTemp, Error, TEXT("Python Plugin not loaded!"));
	} else {
		ExecutePython(FString::Printf(TEXT("SScriptEditorPanel_OpenDocumentation(%s)"), *Text), false);
		
	}
}

AutoComplete

Following the trend I found an auto-completer in the command executor which provided an excellent starting point.
This works by connecting a SListView to an SMenuAnchor as a popup, with a custom FSuggestions struct to contain the current state. I could tidy this up somewhat but why re-invent the wheel?

This is the part of the Panel header dedicated to autocomplete.. which is most of it:

class SScriptEditorPanel : public SCompoundWidget
{
private:
	// Callbacks
    void ShowAutoCompleter();
    void HideAutoCompleter();
	void AcceptAutoCompleter();
	void CompleterNavUp();
	void CompleterNavDown();

	FOptionalSize GetSelectionListMaxWidth() const;
	TSharedRef<ITableRow> MakeSuggestionListItemWidget(TSharedPtr<FString> Message, const TSharedRef<STableViewBase>& OwnerTable);
	void SuggestionSelectionChanged(TSharedPtr<FString> NewValue, ESelectInfo::Type SelectInfo);
	void SetSuggestions(TArray<FString>& Elements, FText Highlight);
	void MarkActiveSuggestion();
	void ClearSuggestions();
	FReply OnCompleterKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent);
	struct FSuggestions
	{
		FSuggestions()
			: SelectedSuggestion(INDEX_NONE) {}

		void Reset() {
			SelectedSuggestion = INDEX_NONE;
			SuggestionsList.Reset();
			SuggestionsHighlight = FText::GetEmpty();
		}

		bool HasSuggestions() const {
			return SuggestionsList.Num() > 0;
		}

		bool HasSelectedSuggestion() const {
			return SuggestionsList.IsValidIndex(SelectedSuggestion);
		}

		void StepSelectedSuggestion(const int32 Step) {
			SelectedSuggestion += Step;
			if (SelectedSuggestion < 0)
			{
				SelectedSuggestion = SuggestionsList.Num() - 1;
			}
			else if (SelectedSuggestion >= SuggestionsList.Num())
			{
				SelectedSuggestion = 0;
			}
		}

		TSharedPtr<FString> GetSelectedSuggestion() const {
			return SuggestionsList.IsValidIndex(SelectedSuggestion) ? SuggestionsList[SelectedSuggestion] : nullptr;
		}

		/** INDEX_NONE if not set, otherwise index into SuggestionsList */
		int32 SelectedSuggestion;

		/** All log messages stored in this widget for the list view */
		TArray<TSharedPtr<FString>> SuggestionsList;

		/** Highlight text to use for the suggestions list */
		FText SuggestionsHighlight;
	};

	TSharedPtr< SMenuAnchor > SuggestionBox;
	TSharedPtr< SOverlay > Overlay;
	TSharedPtr< SListView< TSharedPtr<FString> > > SuggestionListView;
	FSuggestions Suggestions;
	bool bIgnoreUIUpdate;
};

Creating the popup is simply a matter of sticking a widget under a menu anchor under an overlay:

	ChildSlot
    [
		SAssignNew(Overlay, SOverlay)
		+ SOverlay::Slot()
		.HAlign(HAlign_Fill)
		.VAlign(VAlign_Fill)
		[
			SNew(SBorder)
			.BorderImage(FScriptEditorStyle::Get().GetBrush("TextEditor.Background"))
		]

		+ SOverlay::Slot()
		[
			
			SAssignNew( SuggestionBox, SMenuAnchor )
			.Method(PopupMethod)
			.Placement(EMenuPlacement::MenuPlacement_AboveAnchor)
			[
				SNew(SBox)
				.HeightOverride(10)
				.WidthOverride(10)
			]
			.MenuContent
			(
				SNew(SBorder)
				.BorderImage(FScriptEditorStyle::Get().GetBrush("Menu.Background"))
				.Padding( FMargin(2) )
				[
					SNew(SBox)
					.MinDesiredWidth(300)
					.MaxDesiredHeight(250)
					.MaxDesiredWidth(this, &SScriptEditorPanel::GetSelectionListMaxWidth)
					[
						SAssignNew(SuggestionListView, SListView< TSharedPtr<FString>>)
						.ListItemsSource(&Suggestions.SuggestionsList)
						.SelectionMode( ESelectionMode::Single )							// Ideally the mouse over would not highlight while keyboard controls the UI
						.OnGenerateRow(this, &SScriptEditorPanel::MakeSuggestionListItemWidget)
						.OnSelectionChanged(this, &SScriptEditorPanel::SuggestionSelectionChanged)
						.OnKeyDownHandler(this, &SScriptEditorPanel::OnCompleterKeyDown)
						.ItemHeight(18)
					]
				]
			)
		]
	]

The actual code to show the suggestions is not as simple.
We need to get the current selected text and any partial match (are they requesting auto complete straight after the period “.” or have they already started typing.
Has the font size changed? Where should the completer be shown in order to match the caret position.
How do they complete the suggestions? Enter, Right arrow, Tab?

This is the code, half ripped from Epic and updated to work with multi-line editors.
Because Unreal does not give us caret positions we have to calculate it ourselves which is unfortunate.

void SScriptEditorPanel::ShowAutoCompleter()
{
	if(bIgnoreUIUpdate)
		return;
		
	IPythonScriptPlugin* PythonPlugin = IPythonScriptPlugin::Get();
	if ( !PythonPlugin || !PythonPlugin->IsPythonAvailable() ) {
		HideAutoCompleter();
        return;
	}
	FString ObjectString, PartialString;
	if ( !PythonEditor->GetSuggestionText( &ObjectString, &PartialString ) ) {
		HideAutoCompleter();
		return;
	}
	
	if(!ObjectString.IsEmpty()) {
		TArray<FString> AutoCompleteList;

        FPythonCommandEx Ex;
        Ex.ExecutionMode = EPythonCommandExecutionMode::EvaluateStatement;
        Ex.Command = FString::Printf(TEXT("dir(%s)"), *ObjectString );
 
        if ( PythonPlugin->ExecPythonCommandEx(Ex) ) {
			// Remove non string characters from the result, split it by comma into an array
            Ex.CommandResult.Replace(TEXT("'"), TEXT("")).Replace(TEXT("["), TEXT("")).Replace(TEXT("]"), TEXT("")).ParseIntoArray(AutoCompleteList, TEXT(", ") );
		}
		// User has typed a partial match
		if ( PartialString.Len() )
			AutoCompleteList = AutoCompleteList.FilterByPredicate([&PartialString](const FString& Each) { return Each.Contains( PartialString, ESearchCase::IgnoreCase ); });

		SetSuggestions(AutoCompleteList, FText::FromString(PartialString));
	} else {
		ClearSuggestions();
	}
}
void SScriptEditorPanel::SetSuggestions(TArray<FString>& 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<FString>(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)
		TPanelChildren<SOverlay::FOverlaySlot>& OverlaySlots = *(TPanelChildren<SOverlay::FOverlaySlot>*)Overlay->GetChildren();
		FTextLocation Location = PythonEditor->GetTextLocation();
		
		TArray<FString> Lines;
		PythonEditor->GetText().ToString().ParseIntoArrayLines( Lines, false );
		Lines.SetNum( Location.GetLineIndex() + 1, true );
		Lines[Lines.Num() - 1].MidInline(0, Location.GetOffset()+1);
		FString SubText = FString::Join(Lines, TEXT("\n"));

		FVector2D EditorSize = PythonEditor->GetDesiredSize();
		float HScroll = (HorizontalScrollbar->IsNeeded() ? HorizontalScrollbar->DistanceFromTop() : 0.0 ) * EditorSize.X;
		float VScroll = (VerticalScrollbar->IsNeeded() ? VerticalScrollbar->DistanceFromTop() : 0.0 ) * EditorSize.Y;
		const TSharedRef< FSlateFontMeasure > FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
		auto FontInfo = PythonEditor->GetTextStyle().Font;
		float TextBlockHeight = FontMeasure->Measure( SubText, FontInfo, 1.0).Y;
		float TextLineWidth = FontMeasure->Measure( Lines[Lines.Num() - 1], FontInfo, 1.0).X;
		OverlaySlots[1].Padding( TextLineWidth - HScroll, (((TextBlockHeight / Lines.Num()) * (Lines.Num() - 1) )) - VScroll);
		PythonEditor->SetCompleterIsShown(true);
		SuggestionBox->SetIsOpen(true, false);
		if (Suggestions.HasSelectedSuggestion())
			SuggestionListView->RequestScrollIntoView(Suggestions.GetSelectedSuggestion());
		else
			SuggestionListView->ScrollToTop();
	} else {
		HideAutoCompleter();
	}
}
void SScriptEditorPanel::ClearSuggestions()
{
	HideAutoCompleter();
	Suggestions.Reset();
}


FOptionalSize SScriptEditorPanel::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<ITableRow> SScriptEditorPanel::MakeSuggestionListItemWidget(TSharedPtr<FString> Text, const TSharedRef<STableViewBase>& 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<FString> >, OwnerTable)
		[
			SNew(STextBlock)
			.Text(FText::FromString(SanitizedText))
			.HighlightText(Suggestions.SuggestionsHighlight)
		];
}

There is a little more to the UI, but the important things to note of are above. I have provided the source below for you to explore/use at your leisure.

View it in Action:

Script Editor Preview

Conclusion:

This was a fun albeit frustrating project, I feel I have been spoiled in Qt. Slate can do the same things but is missing what feels like a large number of convenience controls.
I failed to implement both line numbers and multiple tabs this week, Line numbers was due to not having the time to setup the painter, but multi-tabs is incredibly convoluted in Slate, I did get it working but only by either specifying all tabs up front or by creating a mini asset eco-system specific to this tool.
This is something I will revisit in the future.

Source Code:

View it on Github!