diff --git a/src/Explorer.csproj b/src/Explorer.csproj index d452a9e..173ba96 100644 --- a/src/Explorer.csproj +++ b/src/Explorer.csproj @@ -256,6 +256,21 @@ + + + + + + + + + + + + + + + diff --git a/src/ExplorerCore.cs b/src/ExplorerCore.cs index 0995ad1..e4be609 100644 --- a/src/ExplorerCore.cs +++ b/src/ExplorerCore.cs @@ -97,10 +97,6 @@ namespace ExplorerBeta m_doneUIInit = true; } } - else - { - UIManager.Update(); - } if (InputManager.GetKeyDown(ModConfig.Instance.Main_Menu_Toggle)) { diff --git a/src/UI/Main/DebugConsole.cs b/src/UI/Main/DebugConsole.cs index 38d8c7a..f0aa950 100644 --- a/src/UI/Main/DebugConsole.cs +++ b/src/UI/Main/DebugConsole.cs @@ -94,14 +94,6 @@ namespace ExplorerBeta.UI.Main tmpInput.verticalScrollbar = scroller; m_textInput = input.GetComponent(); - - for (int i = 0; i < 100; i++) - { - Log("hello " + i); - } - - Log("hello", Color.red); - Log("hello", Color.yellow); } public static void Log(string message) diff --git a/src/UI/Main/MainMenu.cs b/src/UI/Main/MainMenu.cs index c3e1c33..39a482d 100644 --- a/src/UI/Main/MainMenu.cs +++ b/src/UI/Main/MainMenu.cs @@ -49,6 +49,7 @@ namespace ExplorerBeta.UI.Main foreach (var page in Pages) { page.Init(); + page.Content?.SetActive(false); } SetPage(Pages[0]); @@ -56,7 +57,7 @@ namespace ExplorerBeta.UI.Main public void Update() { - // todo + m_activePage?.Update(); } // todo diff --git a/src/UI/Main/Pages/Console/Editor/AutoIndent.cs b/src/UI/Main/Pages/Console/Editor/AutoIndent.cs new file mode 100644 index 0000000..dd66ddb --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/AutoIndent.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Explorer.UI.Main.Pages.Console +{ + public class AutoIndent + { + // Enum + public enum IndentMode + { + None, + AutoTab, + AutoTabContextual, + } + + // Private + private static readonly StringBuilder indentBuilder = new StringBuilder(); + + private static string indentDecreaseString = null; + + // Public + public static IndentMode autoIndentMode = IndentMode.AutoTabContextual; + + /// + /// Should auto indent be used for this language. + /// + public static bool allowAutoIndent = true; + /// + /// The character that causes the indent level to increase. + /// + public static char indentIncreaseCharacter = '{'; + /// + /// The character that causes the indent level to decrease. + /// + public static char indentDecreaseCharacter = '}'; + + // Properties + /// + /// Get the string representation of the indent character. + /// + public static string IndentDecreaseString + { + get + { + if (indentDecreaseString == null) + { + indentDecreaseString = new string(indentDecreaseCharacter, 1); + } + return indentDecreaseString; + } + } + + // Methods + public static string GetAutoIndentedFormattedString(string indentSection, int currentIndent, out int caretPosition) + { + // Add indent level + int indent = currentIndent + 1; + + // Append characters + for (int i = 0; i < indentSection.Length; i++) + { + if (indentSection[i] == '\n') + { + indentBuilder.Append('\n'); + AppendIndentString(indent); + } + else if (indentSection[i] == '\t') + { + // We will add tabs manually + continue; + } + else if (indentSection[i] == indentIncreaseCharacter) + { + indentBuilder.Append(indentIncreaseCharacter); + indent++; + } + else if (indentSection[i] == indentDecreaseCharacter) + { + indentBuilder.Append(indentDecreaseCharacter); + indent--; + } + else + { + indentBuilder.Append(indentSection[i]); + } + } + + // Build the string + string formattedSection = indentBuilder.ToString(); + indentBuilder.Length = 0; + + // Default caret position + caretPosition = formattedSection.Length - 1; + + // Find the caret position + for (int i = formattedSection.Length - 1; i >= 0; i--) + { + if (formattedSection[i] == '\n') + continue; + + caretPosition = i; + break; + } + + return formattedSection; + } + + public static int GetAutoIndentLevel(string inputString, int startIndex, int endIndex) + { + int indent = 0; + + for (int i = startIndex; i < endIndex; i++) + { + if (inputString[i] == '\t') + indent++; + + // Check for end line or other characters + if (inputString[i] == '\n' || inputString[i] != ' ') + break; + } + + return indent; + } + + private static void AppendIndentString(int amount) + { + for (int i = 0; i < amount; i++) + indentBuilder.Append("\t"); + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/CodeEditor.cs b/src/UI/Main/Pages/Console/Editor/CodeEditor.cs new file mode 100644 index 0000000..828cf7e --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/CodeEditor.cs @@ -0,0 +1,552 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using System; +using System.Text; +using System.Reflection; +using ExplorerBeta.Input; +using Explorer.UI.Main.Pages.Console.Lexer; +using ExplorerBeta; + +namespace Explorer.UI.Main.Pages.Console +{ + public class CodeEditor + { + public CodeEditor(TMP_InputField inputField, TextMeshProUGUI inputText, TextMeshProUGUI inputHighlightText, TextMeshProUGUI lineText, + Image background, Image lineHighlight, Image lineNumberBackground, Image scrollbar) + { + this.InputField = inputField; + this.inputText = inputText; + this.inputHighlightText = inputHighlightText; + this.lineText = lineText; + this.background = background; + this.lineHighlight = lineHighlight; + this.lineNumberBackground = lineNumberBackground; + this.scrollbar = scrollbar; + + var highlightTextRect = inputHighlightText.GetComponent(); + highlightTextRect.anchorMin = Vector2.zero; + highlightTextRect.anchorMax = Vector2.one; + highlightTextRect.offsetMin = Vector2.zero; + highlightTextRect.offsetMax = Vector2.zero; + + if (!AllReferencesAssigned()) + { + throw new Exception("CodeEditor: Components are missing!"); + } + + this.inputTextTransform = inputText.GetComponent(); + this.lineHighlightTransform = lineHighlight.GetComponent(); + + ApplyTheme(); + ApplyLanguage(); + + // subscribe to text input changing + InputField.onValueChanged.AddListener(new Action((string s) => { Refresh(); })); + } + + private static readonly KeyCode[] lineChangeKeys = + { + KeyCode.Return, KeyCode.Backspace, KeyCode.UpArrow, + KeyCode.DownArrow, KeyCode.LeftArrow, KeyCode.RightArrow + }; + + private static readonly StringBuilder highlightedBuilder = new StringBuilder(4096); + private static readonly StringBuilder lineBuilder = new StringBuilder(); + + private readonly InputStringLexer lexer = new InputStringLexer(); + private readonly RectTransform inputTextTransform; + private readonly RectTransform lineHighlightTransform; + private string lastText; + private bool lineHighlightLocked; + + public readonly TMP_InputField InputField; + private readonly TextMeshProUGUI inputText; + private readonly TextMeshProUGUI inputHighlightText; + private readonly TextMeshProUGUI lineText; + private readonly Image background; + private readonly Image lineHighlight; + private readonly Image lineNumberBackground; + private readonly Image scrollbar; + + private bool lineNumbers = true; + private int lineNumbersSize = 20; + + public int LineCount { get; private set; } = 0; + public int CurrentLine { get; private set; } = 0; + public int CurrentColumn { get; private set; } = 0; + public int CurrentIndent { get; private set; } = 0; + + public string Text + { + get { return InputField.text; } + set + { + if (!string.IsNullOrEmpty(value)) + { + InputField.text = value; + inputHighlightText.text = value; + } + else + { + InputField.text = string.Empty; + inputHighlightText.text = string.Empty; + } + + inputText.ForceMeshUpdate(false); + } + } + + public string HighlightedText => inputHighlightText.text; + + public bool LineNumbers + { + get { return lineNumbers; } + set + { + lineNumbers = value; + + //RectTransform inputFieldTransform = InputField.transform as RectTransform; + //RectTransform lineNumberBackgroudTransform = lineNumberBackground.transform as RectTransform; + + //// Check for line numbers + //if (lineNumbers == true) + //{ + // // Enable line numbers + // lineNumberBackground.gameObject.SetActive(true); + // lineText.gameObject.SetActive(true); + + // // Set left value + // inputFieldTransform.offsetMin = new Vector2(lineNumbersSize, inputFieldTransform.offsetMin.y); + // lineNumberBackgroudTransform.sizeDelta = new Vector2(lineNumbersSize + 15, lineNumberBackgroudTransform.sizeDelta.y); + //} + //else + //{ + // // Disable line numbers + // lineNumberBackground.gameObject.SetActive(false); + // lineText.gameObject.SetActive(false); + + // // Set left value + // inputFieldTransform.offsetMin = new Vector2(0, inputFieldTransform.offsetMin.y); + //} + } + } + + // todo maybe not needed + public int LineNumbersSize + { + get { return lineNumbersSize; } + set + { + lineNumbersSize = value; + + // Update the line numbers + LineNumbers = lineNumbers; + } + } + + public void Update() + { + // Auto indent + if (AutoIndent.autoIndentMode != AutoIndent.IndentMode.None) + { + // Check for new line + if (InputManager.GetKeyDown(KeyCode.Return)) + { + AutoIndentCaret(); + } + } + + bool focusKeyPressed = false; + + // Check for any focus key pressed + foreach (KeyCode key in lineChangeKeys) + { + if (InputManager.GetKeyDown(key)) + { + focusKeyPressed = true; + break; + } + } + + // Update line highlight + if (focusKeyPressed || InputManager.GetMouseButton(0)) + { + UpdateCurrentLineHighlight(); + } + } + + public void Refresh(bool forceUpdate = false) + { + // Trigger a content change event + DisplayedContentChanged(InputField.text, forceUpdate); + } + + public void SetLineHighlight(int lineNumber, bool lockLineHighlight) + { + // Check if code editor is not active + if (lineNumber < 1 || lineNumber > LineCount) + return; + + //int lineOffset = 0; + //int lineIndex = lineNumber - 1; + + // Highlight the current line + lineHighlightTransform.anchoredPosition = new Vector2(5, + (inputText.textInfo.lineInfo[inputText.textInfo.characterInfo[0].lineNumber].lineHeight * + -(lineNumber - 1)) - 4f + + inputTextTransform.anchoredPosition.y); + + // Lock the line highlight so it cannot be moved + if (lockLineHighlight == true) + LockLineHighlight(); + else + UnlockLineHighlight(); + } + + public void LockLineHighlight() + { + lineHighlightLocked = true; + } + + public void UnlockLineHighlight() + { + lineHighlightLocked = false; + } + + private void DisplayedContentChanged(string newText, bool forceUpdate) + { + // Update caret position + UpdateCurrentLineColumnIndent(); + + // Check for change + if ((!forceUpdate && lastText == newText) || string.IsNullOrEmpty(newText)) + { + if (string.IsNullOrEmpty(newText)) + { + inputHighlightText.text = string.Empty; + } + + // Its possible the text was cleared so we need to sync numbers and highlighter + UpdateCurrentLineNumbers(); + UpdateCurrentLineHighlight(); + return; + } + + inputHighlightText.text = SyntaxHighlightContent(newText); + + // Sync line numbers and update the line highlight + UpdateCurrentLineNumbers(); + UpdateCurrentLineHighlight(); + + this.lastText = newText; + } + + private void UpdateCurrentLineNumbers() + { + // Get the line count + int currentLineCount = inputText.textInfo.lineCount; + + int currentLineNumber = 1; + + // Check for a change in line + if (currentLineCount != LineCount) + { + try + { + // Update line numbers + lineBuilder.Length = 0; + + // Build line numbers string + for (int i = 1; i < currentLineCount + 2; i++) + { + if (i - 1 > 0 && i - 1 < currentLineCount - 1) + { + int characterStart = inputText.textInfo.lineInfo[i - 1].firstCharacterIndex; + int characterCount = inputText.textInfo.lineInfo[i - 1].characterCount; + + if (characterStart >= 0 && characterStart < inputText.text.Length && + characterCount != 0 && !inputText.text.Substring(characterStart, characterCount).Contains("\n")) + { + lineBuilder.Append("\n"); + continue; + } + } + + lineBuilder.Append(currentLineNumber); + lineBuilder.Append('\n'); + + currentLineNumber++; + + if (i - 1 == 0 && i - 1 < currentLineCount - 1) + { + int characterStart = inputText.textInfo.lineInfo[i - 1].firstCharacterIndex; + int characterCount = inputText.textInfo.lineInfo[i - 1].characterCount; + + if (characterStart >= 0 && characterStart < inputText.text.Length && + characterCount != 0 && !inputText.text.Substring(characterStart, characterCount).Contains("\n")) + { + lineBuilder.Append("\n"); + continue; + } + } + } + + // Update displayed line numbers + lineText.text = lineBuilder.ToString(); + LineCount = currentLineCount; + } + catch { } + } + } + + private void UpdateCurrentLineColumnIndent() + { + // Get the current line number + int caret = InputField.caretPosition; + + if (caret < 0 || caret >= inputText.textInfo.characterInfo.Count) + { + while (caret >= 0 && caret >= inputText.textInfo.characterInfo.Count) + caret--; + + if (caret < 0 || caret >= inputText.textInfo.characterInfo.Count) + { + return; + } + } + + CurrentLine = inputText.textInfo.characterInfo[caret].lineNumber; + + // Get the total character count + int charCount = 0; + for (int i = 0; i < CurrentLine; i++) + charCount += inputText.textInfo.lineInfo[i].characterCount; + + // Get the column position + CurrentColumn = caret - charCount; + + CurrentIndent = 0; + + // Check for auto indent allowed + if (AutoIndent.allowAutoIndent) + { + for (int i = 0; i < caret && i < InputField.text.Length; i++) + { + char character = InputField.text[i]; + + // Check for opening indents + if (character == AutoIndent.indentIncreaseCharacter) + CurrentIndent++; + + // Check for closing indents + if (character == AutoIndent.indentDecreaseCharacter) + CurrentIndent--; + } + + // Dont allow negative indents + if (CurrentIndent < 0) + CurrentIndent = 0; + } + } + + private void UpdateCurrentLineHighlight() + { + if (lineHighlightLocked) + return; + + try + { + // unity 2018.2 and older may need lineOffset as 0? not sure + //int lineOffset = 1; + + int caret = InputField.caretPosition - 1; + + var lineHeight = inputText.textInfo.lineInfo[inputText.textInfo.characterInfo[0].lineNumber].lineHeight; + var lineNumber = inputText.textInfo.characterInfo[caret].lineNumber; + var offset = lineNumber + inputTextTransform.anchoredPosition.y; + + lineHighlightTransform.anchoredPosition = new Vector2(5, -(offset * lineHeight)); + } + catch //(Exception e) + { + //ExplorerCore.LogWarning("Exception on Update Line Highlight: " + e); + } + } + + private const string CLOSE_COLOR_TAG = ""; + + private string SyntaxHighlightContent(string inputText) + { + if (!InputTheme.allowSyntaxHighlighting) + return inputText; + + int offset = 0; + + highlightedBuilder.Length = 0; + + foreach (var match in lexer.LexInputString(inputText)) + { + // Copy text before the match + for (int i = offset; i < match.startIndex; i++) + highlightedBuilder.Append(inputText[i]); + + // Add the opening color tag + highlightedBuilder.Append(match.htmlColor); + + // Copy text inbetween the match boundaries + for (int i = match.startIndex; i < match.endIndex; i++) + highlightedBuilder.Append(inputText[i]); + + // Add the closing color tag + highlightedBuilder.Append(CLOSE_COLOR_TAG); + + // Update offset + offset = match.endIndex; + } + + // Copy remaining text + for (int i = offset; i < inputText.Length; i++) + highlightedBuilder.Append(inputText[i]); + + // Convert to string + inputText = highlightedBuilder.ToString(); + + return inputText; + } + + // todo param is probably pointless + private void AutoIndentCaret() + { + if (CurrentIndent > 0) + { + var indent = GetAutoIndentTab(CurrentIndent); + + if (indent.Length > 0) + { + var caretPos = InputField.caretPosition; + + var indentMinusOne = indent.Substring(0, indent.Length - 1); + + // get last index of { + // check it on the next line if its not already + var text = InputField.text; + var sub = InputField.text.Substring(0, InputField.caretPosition); + var lastIndex = sub.LastIndexOf("{"); + var offset = lastIndex - 1; + if (offset >= 0 && text[offset] != '\n' && text[offset] != '\t') + { + var open = "\n" + indentMinusOne; + + InputField.text = text.Insert(offset + 1, open); + + caretPos += open.Length; + } + + // check if should add auto-close } + int numOpen = 0; + int numClose = 0; + char prevChar = default; + foreach (var _char in InputField.text) + { + if (_char == '{') + { + if (prevChar != default && (prevChar == '\\' || prevChar == '{')) + { + if (prevChar == '{') + numOpen--; + } + else + { + numOpen++; + } + } + else if (_char == '}') + { + if (prevChar != default && (prevChar == '\\' || prevChar == '}')) + { + if (prevChar == '}') + numClose--; + } + else + { + numClose++; + } + } + prevChar = _char; + } + if (numOpen > numClose) + { + // add auto-indent closing + indentMinusOne = $"\n{indentMinusOne}}}"; + InputField.text = InputField.text.Insert(caretPos, indentMinusOne); + } + + // insert the actual auto indent now + InputField.text = InputField.text.Insert(caretPos, indent); + + InputField.stringPosition = caretPos + indent.Length; + } + } + + // Update line column and indent positions + UpdateCurrentLineColumnIndent(); + + inputText.text = InputField.text; + inputText.SetText(InputField.text, true); + inputText.Rebuild(CanvasUpdate.Prelayout); + InputField.ForceLabelUpdate(); + InputField.Rebuild(CanvasUpdate.Prelayout); + Refresh(true); + } + + private string GetAutoIndentTab(int amount) + { + string tab = string.Empty; + + for (int i = 0; i < amount; i++) + tab += "\t"; + + return tab; + } + + private void ApplyTheme() + { + // Check for missing references + if (!AllReferencesAssigned()) + throw new Exception("Cannot apply theme because one or more required component references are missing. "); + + // Apply theme colors + InputField.caretColor = InputTheme.caretColor; + inputText.color = InputTheme.textColor; + inputHighlightText.color = InputTheme.textColor; + background.color = InputTheme.backgroundColor; + lineHighlight.color = InputTheme.lineHighlightColor; + lineNumberBackground.color = InputTheme.lineNumberBackgroundColor; + lineText.color = InputTheme.lineNumberTextColor; + scrollbar.color = InputTheme.scrollbarColor; + } + + private void ApplyLanguage() + { + lexer.UseMatchers(CodeTheme.DelimiterSymbols, CodeTheme.Matchers); + } + + private bool AllReferencesAssigned() + { + if (!InputField || + !inputText || + !inputHighlightText || + !lineText || + !background || + !lineHighlight || + !lineNumberBackground || + !scrollbar) + { + // One or more references are not assigned + return false; + } + return true; + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/CodeTheme.cs b/src/UI/Main/Pages/Console/Editor/CodeTheme.cs new file mode 100644 index 0000000..a2875f0 --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/CodeTheme.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using Explorer.UI.Main.Pages.Console.Lexer; +using System.Runtime.InteropServices; + +namespace Explorer.UI.Main.Pages.Console +{ + public static class CodeTheme + { + internal static readonly StringBuilder sharedBuilder = new StringBuilder(); + + private static char[] delimiterSymbolCache = null; + private static MatchLexer[] matchers = null; + + public static string languageName = "C#"; + + public static string delimiterSymbols = "[ ] ( ) { } ; : , ."; + + public static KeywordGroupMatch[] keywordGroups = new KeywordGroupMatch[] + { + // VALID KEYWORDS + + new KeywordGroupMatch() + { + highlightColor = new Color(0.33f, 0.61f, 0.83f, 1.0f), + caseSensitive = true, + keywords = @"add as ascending await base bool break by byte + case catch char checked const continue decimal default descending do dynamic + else enum equals false finally fixed float for foreach from global goto group + if in int into is join let lock long new null object on orderby out params + partial ref remove return sbyte sealed select short sizeof stackalloc string + struct switch this throw true try typeof uint ulong unchecked unsafe ushort + using value var void where while yield" + }, + + // INVALID KEYWORDS (cannot use inside method scope) + + new KeywordGroupMatch() + { + highlightColor = new Color(0.95f, 0.10f, 0.10f, 1.0f), + caseSensitive = true, + keywords = @"abstract async class delegate explicit extern get + implicit interface internal namespace operator override private protected public + readonly set static virtual volatile" + } + }; + + /// + /// A symbol group used to specify which symbols should be highlighted. + /// + public static SymbolGroupMatch symbolGroup = new SymbolGroupMatch + { + symbols = @"[ ] ( ) . ? : + - * / % & | ^ ~ = < > ++ -- && || << >> == != <= >= + += -= *= /= %= &= |= ^= <<= >>= -> ?? =>", + highlightColor = new Color(0.58f, 0.47f, 0.37f, 1.0f), + }; + + /// + /// A number group used to specify whether numbers should be highlighted. + /// + public static NumberGroupMatch numberGroup = new NumberGroupMatch + { + highlightNumbers = true, + highlightColor = new Color(0.58f, 0.33f, 0.33f, 1.0f) + }; + + /// + /// A comment group used to specify which strings open and close comments. + /// + public static CommentGroupMatch commentGroup = new CommentGroupMatch + { + blockCommentEnd = @"*/", + blockCommentStart = @"/*", + lineCommentStart = @"//", + lineCommentHasPresedence = true, + highlightColor = new Color(0.34f, 0.65f, 0.29f, 1.0f), + }; + + /// + /// A literal group used to specify whether quote strings should be highlighted. + /// + public static LiteralGroupMatch literalGroup = new LiteralGroupMatch + { + highlightLiterals = true, + highlightColor = new Color(0.79f, 0.52f, 0.32f, 1.0f) + }; + + ///// + ///// Options group for all auto indent related settings. + ///// + //public static AutoIndent autoIndent; + + // Properties + internal static char[] DelimiterSymbols + { + get + { + if (delimiterSymbolCache == null) + { + // Split by space + string[] symbols = delimiterSymbols.Split(' '); + + int count = 0; + + // Count the number of valid symbols + for (int i = 0; i < symbols.Length; i++) + if (symbols[i].Length == 1) + count++; + + // Allocate array + delimiterSymbolCache = new char[count]; + + // Copy symbols + for (int i = 0, index = 0; i < symbols.Length; i++) + { + // Require only 1 character + if (symbols[i].Length == 1) + { + // Get the first character for the string + delimiterSymbolCache[index] = symbols[i][0]; + index++; + } + } + } + return delimiterSymbolCache; + } + } + + internal static MatchLexer[] Matchers + { + get + { + if (matchers == null) + { + List matcherList = new List + { + commentGroup, + symbolGroup, + numberGroup, + literalGroup + }; + matcherList.AddRange(keywordGroups); + + matchers = matcherList.ToArray(); + } + return matchers; + } + } + + // Methods + internal static void Invalidate() + { + foreach (KeywordGroupMatch group in keywordGroups) + group.Invalidate(); + + symbolGroup.Invalidate(); + commentGroup.Invalidate(); + numberGroup.Invalidate(); + literalGroup.Invalidate(); + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/InputTheme.cs b/src/UI/Main/Pages/Console/Editor/InputTheme.cs new file mode 100644 index 0000000..9299579 --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/InputTheme.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console +{ + public static class InputTheme + { + public static bool allowSyntaxHighlighting = true; + + public static Color caretColor = new Color32(255, 255, 255, 255); + public static Color textColor = new Color32(255, 255, 255, 255); + public static Color backgroundColor = new Color32(37, 37, 37, 255); + public static Color lineHighlightColor = new Color32(50, 50, 50, 255); + public static Color lineNumberBackgroundColor = new Color32(25, 25, 25, 255); + public static Color lineNumberTextColor = new Color32(180, 180, 180, 255); + public static Color scrollbarColor = new Color32(45, 50, 50, 255); + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/CommentGroupMatch.cs b/src/UI/Main/Pages/Console/Editor/Lexer/CommentGroupMatch.cs new file mode 100644 index 0000000..8ba8b40 --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/CommentGroupMatch.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using Explorer.Unstrip.ColorUtility; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + /// + /// Used to match line and block comments. + /// + public sealed class CommentGroupMatch : MatchLexer + { + [NonSerialized] + private string htmlColor = null; + + // Public + /// + /// The string that denotes the start of a line comment. + /// Leave this value empty if line comments should not be highlighted. + /// + public string lineCommentStart; + /// + /// The string that denotes the start of a block comment. + /// Leave this value empty if block comments should not be highlighted. + /// + public string blockCommentStart; + /// + /// The string that denotes the end of a block comment. + /// + public string blockCommentEnd; + /// + /// The color that comments will be highlighted with. + /// + public Color highlightColor = Color.black; + + public bool lineCommentHasPresedence = true; + + // Properties + /// + /// Retrusn a value indicating whether any comment highlighting is enabled. + /// A valid line or block comment start string must be specified in order for comment highlighting to be enabled. + /// + public bool HasCommentHighlighting + { + get + { + return string.IsNullOrEmpty(lineCommentStart) == false || + string.IsNullOrEmpty(blockCommentStart) == false; + } + } + + /// + /// Get the html tag color that comments will be highlighted with. + /// + public override string HTMLColor + { + get + { + // Build html color string + if (htmlColor == null) + htmlColor = "<#" + highlightColor.ToHex() + ">"; + + return htmlColor; + } + } + + /// + /// Returns an enumerable collection of characters from this group that can act as delimiter symbols when they appear after a keyword. + /// + public override IEnumerable SpecialStartCharacters + { + get + { + if (string.IsNullOrEmpty(lineCommentStart) == false) + yield return lineCommentStart[0]; + + if (string.IsNullOrEmpty(blockCommentEnd) == false) + yield return blockCommentEnd[0]; + } + } + + /// + /// Returns an enumerable collection of characters from this group that can act as delimiter symbols when they appear before a keyword. + /// + public override IEnumerable SpecialEndCharacters + { + get + { + if (string.IsNullOrEmpty(blockCommentEnd) == false) + yield return blockCommentEnd[blockCommentEnd.Length - 1]; + } + } + + // Methods + /// + /// Causes the cached values to be reloaded. + /// Useful for editor visualisation. + /// + public override void Invalidate() + { + this.htmlColor = null; + } + + /// + /// Returns true if the lexer input contains a valid comment format as the next character sequence. + /// + /// The input lexer + /// True if a comment was found or false if not + public override bool IsImplicitMatch(ILexer lexer) + { + if (lineCommentHasPresedence == true) + { + // Parse line comments then block comments + if (IsLineCommentMatch(lexer) == true || + IsBlockCommentMatch(lexer) == true) + return true; + } + else + { + // Parse block comments then line coments + if (IsBlockCommentMatch(lexer) == true || + IsLineCommentMatch(lexer) == true) + return true; + } + + // Not a comment + return false; + } + + private bool IsLineCommentMatch(ILexer lexer) + { + // Check for line comment + if (string.IsNullOrEmpty(lineCommentStart) == false) + { + lexer.Rollback(); + + bool match = true; + + for (int i = 0; i < lineCommentStart.Length; i++) + { + if (lineCommentStart[i] != lexer.ReadNext()) + { + match = false; + break; + } + } + + // Check for valid match + if (match == true) + { + // Read until end + while (IsEndLineOrEndFile(lexer, lexer.ReadNext()) == false) ; + + // Matched a single line comment + return true; + } + } + return false; + } + + private bool IsBlockCommentMatch(ILexer lexer) + { + // Check for block comment + if (string.IsNullOrEmpty(blockCommentStart) == false) + { + lexer.Rollback(); + + bool match = true; + + for (int i = 0; i < blockCommentStart.Length; i++) + { + if (blockCommentStart[i] != lexer.ReadNext()) + { + match = false; + break; + } + } + + // Check for valid match + if (match == true) + { + // Read until end or closing block + while (IsEndLineOrString(lexer, blockCommentEnd) == false) ; + + // Matched a multi-line block commend + return true; + } + } + return false; + } + + private bool IsEndLineOrEndFile(ILexer lexer, char character) + { + if (lexer.EndOfStream == true || + character == '\n' || + character == '\r') + { + // Line end or file end + return true; + } + return false; + } + + private bool IsEndLineOrString(ILexer lexer, string endString) + { + for (int i = 0; i < endString.Length; i++) + { + // Check for end of stream + if (lexer.EndOfStream == true) + return true; + + // Check for matching end string + if (endString[i] != lexer.ReadNext()) + return false; + } + + // We matched the end string + return true; + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/ILexer.cs b/src/UI/Main/Pages/Console/Editor/Lexer/ILexer.cs new file mode 100644 index 0000000..112528f --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/ILexer.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + // Types + /// + /// Represents a keyword position where a special character may appear. + /// + public enum SpecialCharacterPosition + { + /// + /// The special character may appear before a keyword. + /// + Start, + /// + /// The special character may appear after a keyword. + /// + End, + }; + + /// + /// Represents a streamable lexer input which can be examined by matchers. + /// + public interface ILexer + { + // Properties + /// + /// Returns true if there are no more characters to read. + /// + bool EndOfStream { get; } + + /// + /// Returns the previously read character or '\0' if there is no previous character. + /// + char Previous { get; } + + // Methods + /// + /// Attempt to read the next character. + /// + /// The next character in the stream or '\0' if the end of stream is reached + char ReadNext(); + + /// + /// Causes the lexer to return its state to a previously commited state. + /// + /// Use -1 to return to the last commited state or a positive number to represent the number of characters to rollback + void Rollback(int amount = -1); + + /// + /// Causes all read characters to be commited meaning that rollback will return to this lexer state. + /// + void Commit(); + + /// + /// Determines whether the specified character is considered a special symbol by the lexer meaning that it is able to act as a delimiter. + /// + /// The character to check + /// The character position to check. This determines whether the character may appear at the start or end of a keyword + /// True if the character is a valid delimiter or false if not + bool IsSpecialSymbol(char character, SpecialCharacterPosition position = SpecialCharacterPosition.Start); + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/InputStringLexer.cs b/src/UI/Main/Pages/Console/Editor/Lexer/InputStringLexer.cs new file mode 100644 index 0000000..6afc22c --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/InputStringLexer.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + internal struct InputStringMatchInfo + { + // Public + public int startIndex; + public int endIndex; + public string htmlColor; + } + + internal class InputStringLexer : ILexer + { + // Private + private string inputString = null; + private MatchLexer[] matchers = null; + private readonly HashSet specialStartSymbols = new HashSet(); + private readonly HashSet specialEndSymbols = new HashSet(); + private char current = ' '; + private char previous = ' '; + private int currentIndex = 0; + private int currentLookaheadIndex = 0; + + // Properties + public bool EndOfStream + { + get { return currentLookaheadIndex >= inputString.Length; } + } + + public char Previous + { + get { return previous; } + } + + // Methods + public void UseMatchers(char[] delimiters, MatchLexer[] matchers) + { + // Store matchers + this.matchers = matchers; + + // Clear old symbols + specialStartSymbols.Clear(); + specialEndSymbols.Clear(); + + // Check for any delimiter characters + if (delimiters != null) + { + // Add delimiters + foreach (char character in delimiters) + { + // Add to start + if (specialStartSymbols.Contains(character) == false) + specialStartSymbols.Add(character); + + // Add to end + if (specialEndSymbols.Contains(character) == false) + specialEndSymbols.Add(character); + } + } + + // Check for any matchers + if (matchers != null) + { + // Add all special symbols which can act as a delimiter + foreach (MatchLexer lexer in matchers) + { + foreach (char special in lexer.SpecialStartCharacters) + if (specialStartSymbols.Contains(special) == false) + specialStartSymbols.Add(special); + + foreach (char special in lexer.SpecialEndCharacters) + if (specialEndSymbols.Contains(special) == false) + specialEndSymbols.Add(special); + } + } + } + + public IEnumerable LexInputString(string input) + { + if (input == null || matchers == null || matchers.Length == 0) + yield break; + + // Store the input string + this.inputString = input; + this.current = ' '; + this.previous = ' '; + this.currentIndex = 0; + this.currentLookaheadIndex = 0; + + // Process the input string + while (EndOfStream == false) + { + bool didMatchLexer = false; + + // Read any white spaces + ReadWhiteSpace(); + + // Process each matcher + foreach (MatchLexer matcher in matchers) + { + // Get the current index + int startIndex = currentIndex; + + // Try to match + bool isMatched = matcher.IsMatch(this); + + if (isMatched == true) + { + // Get the end index of the match + int endIndex = currentIndex; + + // Set matched flag + didMatchLexer = true; + + // Register the match + yield return new InputStringMatchInfo + { + startIndex = startIndex, + endIndex = endIndex, + htmlColor = matcher.HTMLColor, + }; + + // Move to next character + break; + } + } + + if (didMatchLexer == false) + { + // Move to next + ReadNext(); + Commit(); + } + } + } + + public char ReadNext() + { + // Check for end of stream + if (EndOfStream == true) + return '\0'; + + // Update previous character + previous = current; + + // Get the character + current = inputString[currentLookaheadIndex]; + currentLookaheadIndex++; + + return current; + } + + public void Rollback(int amount = -1) + { + if (amount == -1) + { + // Revert to index + currentLookaheadIndex = currentIndex; + } + else + { + if (currentLookaheadIndex > currentIndex) + currentLookaheadIndex -= amount; + } + + int previousIndex = currentLookaheadIndex - 1; + + if (previousIndex >= inputString.Length) + previous = inputString[inputString.Length - 1]; + else if (previousIndex >= 0) + previous = inputString[previousIndex]; + else + previous = ' '; + } + + public void Commit() + { + currentIndex = currentLookaheadIndex; + } + + public bool IsSpecialSymbol(char character, SpecialCharacterPosition position = SpecialCharacterPosition.Start) + { + if (position == SpecialCharacterPosition.Start) + return specialStartSymbols.Contains(character); + + return specialEndSymbols.Contains(character); + } + + private void ReadWhiteSpace() + { + // Read until white space + while (char.IsWhiteSpace(ReadNext()) == true) + { + // Consume the char + Commit(); + } + + // Return lexer state + Rollback(); + } + } +} \ No newline at end of file diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/KeywordGroupMatch.cs b/src/UI/Main/Pages/Console/Editor/Lexer/KeywordGroupMatch.cs new file mode 100644 index 0000000..1f8254c --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/KeywordGroupMatch.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Explorer.Unstrip.ColorUtility; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + /// + /// A matcher that checks for a number of predefined keywords in the lexer stream. + /// + public sealed class KeywordGroupMatch : MatchLexer + { + // Private + private static readonly HashSet shortlist = new HashSet(); + private static readonly Stack removeList = new Stack(); + private string[] keywordCache = null; + private string htmlColor = null; + + // Public + /// + /// Used for editor gui only. Has no purpose other than to give the inspector foldout a nice name + /// + public string group = "Keyword Group"; // This value is not used - it just gives the inspector foldout a nice name + /// + /// A string containing one or more keywords separated by a space character that will be used by this matcher. + /// + public string keywords; + /// + /// The color that any matched keywords will be highlighted. + /// + public Color highlightColor = Color.black; + /// + /// Should keyword matching be case sensitive. + /// + public bool caseSensitive = true; + + // Properties + /// + /// Get a value indicating whether keyword highlighting is enabled based upon the number of valid keywords found. + /// + public bool HasKeywordHighlighting + { + get + { + // Check for valid keyword + if (string.IsNullOrEmpty(keywords) == false) + return true; + + return false; + } + } + + /// + /// Get the html formatted color tag that any matched keywords will be highlighted with. + /// + public override string HTMLColor + { + get + { + // Get html color + if (htmlColor == null) + htmlColor = "<#" + highlightColor.ToHex() + ">"; + + return htmlColor; + } + } + + // Methods + /// + /// Causes any cached data to be reloaded. + /// + public override void Invalidate() + { + this.htmlColor = null; + } + + /// + /// Check whether the specified lexer has a valid keyword at its current position. + /// + /// The input lexer to check + /// True if the stream has a keyword or false if not + public override bool IsImplicitMatch(ILexer lexer) + { + // Make sure cache is built + BuildKeywordCache(); + + // Require whitespace before character + if (char.IsWhiteSpace(lexer.Previous) == false && + lexer.IsSpecialSymbol(lexer.Previous, SpecialCharacterPosition.End) == false) + return false; + + // Clear old data + shortlist.Clear(); + + // Read the first character + int currentIndex = 0; + char currentChar = lexer.ReadNext(); + + // Add to shortlist + for (int i = 0; i < keywordCache.Length; i++) + if (CompareChar(keywordCache[i][0], currentChar) == true) + shortlist.Add(keywordCache[i]); + + // Check for no matches we can skip the heavy work quickly + if (shortlist.Count == 0) + return false; + + do + { + // Check for end of stream + if (lexer.EndOfStream == true) + { + RemoveLongStrings(currentIndex + 1); + break; + } + + // Read next character + currentChar = lexer.ReadNext(); + currentIndex++; + + // Check for end of word + if (char.IsWhiteSpace(currentChar) == true || + lexer.IsSpecialSymbol(currentChar, SpecialCharacterPosition.Start) == true) + { + // Finalize any matching candidates and undo the reading of the space or special character + RemoveLongStrings(currentIndex); + lexer.Rollback(1); + break; + } + + // Check for shortlist match + foreach (string keyword in shortlist) + { + if (currentIndex >= keyword.Length || + CompareChar(keyword[currentIndex], currentChar) == false) + { + removeList.Push(keyword); + } + } + + // Remove from short list + while (removeList.Count > 0) + shortlist.Remove(removeList.Pop()); + } + while (shortlist.Count > 0); + + // Check for any words in the shortlist + return shortlist.Count > 0; + } + + private void RemoveLongStrings(int length) + { + foreach (string keyword in shortlist) + { + if (keyword.Length > length) + { + removeList.Push(keyword); + } + } + + // Remove from short list + while (removeList.Count > 0) + shortlist.Remove(removeList.Pop()); + } + + private void BuildKeywordCache() + { + // Check if we need to build the cache + if (keywordCache == null) + { + // Get keyowrds and insert them into a cache array for quick reference + var kwSplit = keywords.Split(' '); + + var list = new List(); + foreach (var kw in kwSplit) + { + if (!string.IsNullOrEmpty(kw) && kw.Length > 0) + { + list.Add(kw); + } + } + keywordCache = list.ToArray(); + } + } + + private bool CompareChar(char a, char b) + { + // Check for direct match + if (a == b) + return true; + + // Check for case sensitive + if (caseSensitive == false) + { + if (char.ToUpper(a, CultureInfo.CurrentCulture) == + char.ToUpper(b, CultureInfo.CurrentCulture)) + { + // Check for match ignoring case + return true; + } + } + return false; + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/LiteralGroupMatch.cs b/src/UI/Main/Pages/Console/Editor/Lexer/LiteralGroupMatch.cs new file mode 100644 index 0000000..4601148 --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/LiteralGroupMatch.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Explorer.Unstrip.ColorUtility; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + /// + /// A matcher that checks for quote strings in the lexer stream. + /// + [Serializable] + public sealed class LiteralGroupMatch : MatchLexer + { + // Private + private string htmlColor = null; + + // Public + /// + /// Should literal be highlighted. + /// When true, any text surrounded by double quotes will be highlighted. + /// + public bool highlightLiterals = true; + /// + /// The color that any matched literals will be highlighted. + /// + public Color highlightColor = Color.black; + + // Properties + /// + /// Get a value indicating whether literal highlighting is enabled. + /// + public bool HasLiteralHighlighting + { + get { return highlightLiterals; } + } + + /// + /// Get the html formatted color tag that any matched literals will be highlighted with. + /// + public override string HTMLColor + { + get + { + if (htmlColor == null) + htmlColor = "<#" + highlightColor.ToHex() + ">"; + + return htmlColor; + } + } + + /// + /// Returns special symbols that can act as delimiters when appearing before a word. + /// In this case '"' will be returned. + /// + public override IEnumerable SpecialStartCharacters + { + get + { + yield return '"'; + } + } + + /// + /// Returns special symbols that can act as delimiters when appearing after a word. + /// In this case '"' will be returned. + /// + public override IEnumerable SpecialEndCharacters + { + get + { + yield return '"'; + } + } + + // Methods + /// + /// Causes any cached data to be reloaded. + /// + public override void Invalidate() + { + this.htmlColor = null; + } + + /// + /// Check whether the specified lexer has a valid literal at its current position. + /// + /// The input lexer to check + /// True if the stream has a literal or false if not + public override bool IsImplicitMatch(ILexer lexer) + { + // Skip highlighting + if (highlightLiterals == false) + return false; + + // Check for quote + if (lexer.ReadNext() == '"') + { + // Read all characters inside the quote + while (IsClosingQuoteOrEndFile(lexer, lexer.ReadNext()) == false) ; + + // Found a valid literal + return true; + } + return false; + } + + private bool IsClosingQuoteOrEndFile(ILexer lexer, char character) + { + if (lexer.EndOfStream == true || + character == '"') + { + // We have found the end of the file or quote + return true; + } + return false; + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/MatchLexer.cs b/src/UI/Main/Pages/Console/Editor/Lexer/MatchLexer.cs new file mode 100644 index 0000000..97560c2 --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/MatchLexer.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + public abstract class MatchLexer + { + /// + /// Get the html formatted color tag that any matched text will be highlighted with. + /// + public abstract string HTMLColor { get; } + + /// + /// Get an enumerable collection of special characters that can act as delimiter symbols when they appear before a word. + /// + public virtual IEnumerable SpecialStartCharacters { get { yield break; } } + + /// + /// Get an enumerable collection of special characters that can act as delimiter symbols when they appear after a word. + /// + public virtual IEnumerable SpecialEndCharacters { get { yield break; } } + + // Methods + /// + /// Checks the specified lexers current position for a certain sequence of characters as defined by the inheriting matcher. + /// + /// + /// + public abstract bool IsImplicitMatch(ILexer lexer); + + /// + /// Causes the matcher to invalidate any cached data forcing it to be regenerated or reloaded. + /// + public virtual void Invalidate() { } + + /// + /// Attempts to check for a match in the specified lexer. + /// + /// The lexer that will be checked + /// True if a match was found or false if not + public bool IsMatch(ILexer lexer) + { + // Check for implicit match + bool match = IsImplicitMatch(lexer); + + if (match == true) + { + // Consume read tokens + lexer.Commit(); + } + else + { + // Revert lexer state + lexer.Rollback(); + } + + return match; + } + } +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/NumberGroupMatch.cs b/src/UI/Main/Pages/Console/Editor/Lexer/NumberGroupMatch.cs new file mode 100644 index 0000000..2effc0a --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/NumberGroupMatch.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEngine; +using Explorer.Unstrip.ColorUtility; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + /// + /// A matcher that checks for any numbers that appear in the lexer stream. + /// + public sealed class NumberGroupMatch : MatchLexer + { + // Private + private string htmlColor = null; + + // Public + /// + /// Should number highlighting be used. + /// When false, numbers will appear in the default text color as defined by the current editor theme. + /// + public bool highlightNumbers = true; + /// + /// The color that any matched numbers will be highlighted. + /// + public Color highlightColor = Color.black; + + // Properties + /// + /// Get a value indicating whether keyword highlighting is enabled. + /// + public bool HasNumberHighlighting + { + get { return highlightNumbers; } + } + + /// + /// Get the html formatted color tag that any matched numbers will be highlighted with. + /// + public override string HTMLColor + { + get + { + if (htmlColor == null) + htmlColor = "<#" + highlightColor.ToHex() + ">"; + + return htmlColor; + } + } + + // Methods + /// + /// Causes any cached data to be reloaded. + /// + public override void Invalidate() + { + this.htmlColor = null; + } + + /// + /// Check whether the specified lexer has a valid number sequence at its current position. + /// + /// The input lexer to check + /// True if the stream has a number sequence or false if not + public override bool IsImplicitMatch(ILexer lexer) + { + // Skip highlighting + if (highlightNumbers == false) + return false; + + // Require whitespace or symbols before numbers + if (char.IsWhiteSpace(lexer.Previous) == false && + lexer.IsSpecialSymbol(lexer.Previous, SpecialCharacterPosition.End) == false) + { + // There is some other character before the potential number + return false; + } + + bool matchedNumber = false; + + // Consume the number characters + while (lexer.EndOfStream == false) + { + // Check for valid numerical character + if (IsNumberOrDecimalPoint(lexer.ReadNext()) == true) + { + // We have found a number or decimal + matchedNumber = true; + lexer.Commit(); + } + else + { + lexer.Rollback(); + break; + } + } + + return matchedNumber; + } + + private bool IsNumberOrDecimalPoint(char character) => char.IsNumber(character) || character == '.'; + } + +} diff --git a/src/UI/Main/Pages/Console/Editor/Lexer/SymbolGroupMatch.cs b/src/UI/Main/Pages/Console/Editor/Lexer/SymbolGroupMatch.cs new file mode 100644 index 0000000..53dbced --- /dev/null +++ b/src/UI/Main/Pages/Console/Editor/Lexer/SymbolGroupMatch.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Explorer.Unstrip.ColorUtility; +using ExplorerBeta; +using UnityEngine; + +namespace Explorer.UI.Main.Pages.Console.Lexer +{ + /// + /// A matcher that checks for a number of predefined symbols in the lexer stream. + /// + [Serializable] + public sealed class SymbolGroupMatch : MatchLexer + { + // Private + private static readonly List shortlist = new List(); + private static readonly Stack removeList = new Stack(); + [NonSerialized] + private string[] symbolCache = null; + [NonSerialized] + private string htmlColor = null; + + // Public + /// + /// A string containing one or more symbols separated by a space character that will be used by the matcher. + /// Symbols can be one or more characters long and should not contain numbers or letters. + /// + public string symbols; + /// + /// The color that any matched symbols will be highlighted. + /// + public Color highlightColor = Color.black; + + // Properties + /// + /// Get a value indicating whether symbol highlighting is enabled based upon the number of valid symbols found. + /// + public bool HasSymbolHighlighting + { + get { return symbols.Length > 0; } + } + + /// + /// Get the html formatted color tag that any matched symbols will be highlighted with. + /// + public override string HTMLColor + { + get + { + // Get html color + if (htmlColor == null) + htmlColor = "<#" + highlightColor.ToHex() + ">"; + + return htmlColor; + } + } + + /// + /// Returns special symbols that can act as delimiters when appearing before a word. + /// + public override IEnumerable SpecialStartCharacters + { + get + { + // Make sure cahce is created + BuildSymbolCache(); + + // Get the first character of each symbol + foreach (string symbol in symbolCache.Where(x => x.Length > 0)) + yield return symbol[0]; + } + } + + /// + /// Returns special symbols that can act as delimiters when appearing after a word. + /// In this case '"' will be returned. + /// + public override IEnumerable SpecialEndCharacters + { + get + { + // Make sure cahce is created + BuildSymbolCache(); + + // Get the first character of each symbol + foreach (string symbol in symbolCache.Where(x => x.Length > 0)) + yield return symbol[0]; + } + } + + // Methods + /// + /// Causes any cached data to be reloaded. + /// + public override void Invalidate() + { + this.htmlColor = null; + } + + /// + /// Checks whether the specified lexer has a valid symbol at its current posiiton. + /// + /// The input lexer to check + /// True if the stream has a symbol or false if not + public override bool IsImplicitMatch(ILexer lexer) + { + // Make sure cache is created + BuildSymbolCache(); + + if (lexer == null) + { + return false; + } + + // Require whitespace, letter or digit before symbol + if (char.IsWhiteSpace(lexer.Previous) == false && + char.IsLetter(lexer.Previous) == false && + char.IsDigit(lexer.Previous) == false && + lexer.IsSpecialSymbol(lexer.Previous, SpecialCharacterPosition.End) == false) + return false; + + // Clear old data + shortlist.Clear(); + + // Read the first character + int currentIndex = 0; + char currentChar = lexer.ReadNext(); + + // Add to shortlist + for (int i = symbolCache.Length - 1; i >= 0; i--) + { + if (symbolCache[i][0] == currentChar) + shortlist.Add(symbolCache[i]); + } + + // No potential matches + if (shortlist.Count == 0) + return false; + + do + { + // Check for end of stream + if (lexer.EndOfStream == true) + { + RemoveLongStrings(currentIndex + 1); + break; + } + + // Read next character + currentChar = lexer.ReadNext(); + currentIndex++; + + if (char.IsWhiteSpace(currentChar) == true || + char.IsLetter(currentChar) == true || + char.IsDigit(currentChar) == true || + lexer.IsSpecialSymbol(currentChar, SpecialCharacterPosition.Start) == true) + { + RemoveLongStrings(currentIndex); + lexer.Rollback(1); + break; + } + + // Check for shortlist match + foreach (string symbol in shortlist) + { + if (currentIndex >= symbol.Length || + symbol[currentIndex] != currentChar) + { + removeList.Push(symbol); + } + } + + // Remove from short list + while (removeList.Count > 0) + shortlist.Remove(removeList.Pop()); + } + while (shortlist.Count > 0); + + // Check for any words in the shortlist + return shortlist.Count > 0; + } + + private void RemoveLongStrings(int length) + { + foreach (string keyword in shortlist) + { + if (keyword.Length > length) + { + removeList.Push(keyword); + } + } + + // Remove from short list + while (removeList.Count > 0) + shortlist.Remove(removeList.Pop()); + } + + private void BuildSymbolCache() + { + if (string.IsNullOrEmpty(symbols)) + { + ExplorerCore.LogWarning("Symbol cache is null!"); + symbolCache = new string[0]; + } + else + { + // Get symbols and insert them into a cache array for quick reference + var symSplit = symbols.Split(' '); + var list = new List(); + foreach (var sym in symSplit) + { + if (!string.IsNullOrEmpty(sym) && sym.Length > 0) + { + list.Add(sym); + } + } + symbolCache = list.ToArray(); + } + } + } +} diff --git a/src/UI/Main/Pages/Console/ScriptEvaluator/AutoComplete.cs b/src/UI/Main/Pages/Console/ScriptEvaluator/AutoComplete.cs new file mode 100644 index 0000000..dbc22f0 --- /dev/null +++ b/src/UI/Main/Pages/Console/ScriptEvaluator/AutoComplete.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using ExplorerBeta; +using UnityEngine; + +// Thanks to ManlyMarco for this + +namespace Explorer.UI.Main.Pages.Console +{ + public struct AutoComplete + { + public string Full => Prefix + Addition; + + public readonly string Prefix; + public readonly string Addition; + public readonly Contexts Context; + + public Color TextColor => Context == Contexts.Namespace + ? Color.gray + : Color.white; + + public AutoComplete(string addition, string prefix, Contexts type) + { + Addition = addition; + Prefix = prefix; + Context = type; + } + + public enum Contexts + { + Namespace, + Other + } + } + + public static class AutoCompleteHelpers + { + public static HashSet Namespaces => _namespaces ?? GetNamespaces(); + private static HashSet _namespaces; + + private static HashSet GetNamespaces() + { + var set = new HashSet( + AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(GetTypes) + .Where(x => x.IsPublic && !string.IsNullOrEmpty(x.Namespace)) + .Select(x => x.Namespace)); + + return _namespaces = set; + + IEnumerable GetTypes(Assembly asm) => asm.TryGetTypes(); + } + } +} diff --git a/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptEvaluator.cs b/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptEvaluator.cs new file mode 100644 index 0000000..86c4390 --- /dev/null +++ b/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptEvaluator.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Mono.CSharp; + +// Thanks to ManlyMarco for this + +namespace Explorer.UI.Main.Pages.Console +{ + internal class ScriptEvaluator : Evaluator, IDisposable + { + private static readonly HashSet StdLib = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "mscorlib", "System.Core", "System", "System.Xml" + }; + + private readonly TextWriter _logger; + + public ScriptEvaluator(TextWriter logger) : base(BuildContext(logger)) + { + _logger = logger; + + ImportAppdomainAssemblies(ReferenceAssembly); + AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; + } + + public void Dispose() + { + AppDomain.CurrentDomain.AssemblyLoad -= OnAssemblyLoad; + _logger.Dispose(); + } + + private void OnAssemblyLoad(object sender, AssemblyLoadEventArgs args) + { + string name = args.LoadedAssembly.GetName().Name; + if (StdLib.Contains(name)) + return; + ReferenceAssembly(args.LoadedAssembly); + } + + private static CompilerContext BuildContext(TextWriter tw) + { + var reporter = new StreamReportPrinter(tw); + + var settings = new CompilerSettings + { + Version = LanguageVersion.Experimental, + GenerateDebugInfo = false, + StdLib = true, + Target = Target.Library, + WarningLevel = 0, + EnhancedWarnings = false + }; + + return new CompilerContext(settings, reporter); + } + + private static void ImportAppdomainAssemblies(Action import) + { + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + string name = assembly.GetName().Name; + if (StdLib.Contains(name)) + continue; + import(assembly); + } + } + } +} diff --git a/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptInteraction.cs b/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptInteraction.cs new file mode 100644 index 0000000..73fc83e --- /dev/null +++ b/src/UI/Main/Pages/Console/ScriptEvaluator/ScriptInteraction.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Mono.CSharp; +using UnityEngine; +using ExplorerBeta; + +namespace Explorer.UI.Main +{ + public class ScriptInteraction : InteractiveBase + { + public static void Log(object message) + { + ExplorerCore.Log(message); + } + + public static object CurrentTarget() + { + throw new NotImplementedException("TODO"); + } + + public static object[] AllTargets() + { + throw new NotImplementedException("TODO"); + } + + public static void Inspect(object obj) + { + throw new NotImplementedException("TODO"); + } + + public static void Inspect(Type type) + { + throw new NotImplementedException("TODO"); + } + + public static void Help() + { + ExplorerCore.Log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); + ExplorerCore.Log(" C# Console Help "); + ExplorerCore.Log(""); + ExplorerCore.Log("The following helper methods are available:"); + ExplorerCore.Log(""); + ExplorerCore.Log("void Log(object message)"); + ExplorerCore.Log(" prints a message to the console window and debug log"); + ExplorerCore.Log(" usage: Log(\"hello world\");"); + ExplorerCore.Log(""); + ExplorerCore.Log("object CurrentTarget()"); + ExplorerCore.Log(" returns the target object of the current tab (in tab view mode only)"); + ExplorerCore.Log(" usage: var target = CurrentTarget();"); + ExplorerCore.Log(""); + ExplorerCore.Log("object[] AllTargets()"); + ExplorerCore.Log(" returns an object[] array containing all currently inspected objects"); + ExplorerCore.Log(" usage: var targets = AllTargets();"); + ExplorerCore.Log(""); + ExplorerCore.Log("void Inspect(object obj)"); + ExplorerCore.Log(" inspects the provided object in a new window."); + ExplorerCore.Log(" usage: Inspect(Camera.main);"); + ExplorerCore.Log(""); + ExplorerCore.Log("void Inspect(Type type)"); + ExplorerCore.Log(" attempts to inspect the provided type with static-only reflection."); + ExplorerCore.Log(" usage: Inspect(typeof(Camera));"); + } + } +} \ No newline at end of file diff --git a/src/UI/Main/Pages/ConsolePage.cs b/src/UI/Main/Pages/ConsolePage.cs index fb90d31..bd0b42e 100644 --- a/src/UI/Main/Pages/ConsolePage.cs +++ b/src/UI/Main/Pages/ConsolePage.cs @@ -1,7 +1,17 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; +using Explorer.UI.Main.Pages.Console; +using ExplorerBeta; +using ExplorerBeta.UI; +using ExplorerBeta.UI.Main; +using ExplorerBeta.Unstrip.Resources; +using TMPro; +using UnhollowerRuntimeLib; +using UnityEngine; +using UnityEngine.UI; namespace Explorer.UI.Main.Pages { @@ -9,14 +19,350 @@ namespace Explorer.UI.Main.Pages { public override string Name => "C# Console"; + public static ConsolePage Instance { get; private set; } + + private CodeEditor codeEditor; + + private ScriptEvaluator m_evaluator; + + public static List AutoCompletes = new List(); + public static List UsingDirectives; + + public static readonly string[] DefaultUsing = new string[] + { + "System", + "UnityEngine", + "System.Linq", + "System.Collections", + "System.Collections.Generic", + "System.Reflection" + }; + public override void Init() { + Instance = this; + try + { + ResetConsole(); + + foreach (var use in DefaultUsing) + { + AddUsing(use); + } + + ConstructUI(); + } + catch (Exception e) + { + ExplorerCore.LogWarning($"Error setting up console!\r\nMessage: {e.Message}"); + // TODO + } } public override void Update() { + codeEditor?.Update(); + } + internal string AsmToUsing(string asm, bool richText = false) + { + if (richText) + { + return $"using {asm};"; + } + return $"using {asm};"; + } + + public void AddUsing(string asm) + { + if (!UsingDirectives.Contains(asm)) + { + UsingDirectives.Add(asm); + Evaluate(AsmToUsing(asm), true); + } + } + + public object Evaluate(string str, bool suppressWarning = false) + { + object ret = VoidType.Value; + + m_evaluator.Compile(str, out var compiled); + + try + { + if (compiled == null) + { + throw new Exception("Mono.Csharp Service was unable to compile the code provided."); + } + + compiled.Invoke(ref ret); + } + catch (Exception e) + { + if (!suppressWarning) + { + ExplorerCore.LogWarning(e.GetType() + ", " + e.Message); + } + } + + return ret; + } + + public void ResetConsole() + { + if (m_evaluator != null) + { + m_evaluator.Dispose(); + } + + m_evaluator = new ScriptEvaluator(new StringWriter(new StringBuilder())) { InteractiveBaseClass = typeof(ScriptInteraction) }; + + UsingDirectives = new List(); + } + + #region UI Construction + + public void ConstructUI() + { + Content = UIFactory.CreateUIObject("C# Console", MainMenu.Instance.PageViewport); + + var mainLayout = Content.AddComponent(); + mainLayout.preferredHeight = 300; + mainLayout.flexibleHeight = 4; + + var mainGroup = Content.AddComponent(); + mainGroup.childControlHeight = true; + mainGroup.childControlWidth = true; + mainGroup.childForceExpandHeight = true; + mainGroup.childForceExpandWidth = true; + + var topBarObj = UIFactory.CreateHorizontalGroup(Content); + var topBarLayout = topBarObj.AddComponent(); + topBarLayout.preferredHeight = 50; + topBarLayout.flexibleHeight = 0; + + var topBarGroup = topBarObj.GetComponent(); + topBarGroup.padding.left = 30; + topBarGroup.padding.right = 30; + topBarGroup.padding.top = 8; + topBarGroup.padding.bottom = 8; + topBarGroup.spacing = 10; + topBarGroup.childForceExpandHeight = true; + topBarGroup.childForceExpandWidth = true; + topBarGroup.childControlWidth = true; + topBarGroup.childControlHeight = true; + + var topBarLabel = UIFactory.CreateLabel(topBarObj, TextAnchor.MiddleLeft); + var topBarLabelLayout = topBarLabel.AddComponent(); + topBarLabelLayout.preferredWidth = 800; + topBarLabelLayout.flexibleWidth = 10; + var topBarText = topBarLabel.GetComponent(); + topBarText.text = "C# Console"; + topBarText.fontSize = 20; + + var compileBtnObj = UIFactory.CreateButton(topBarObj); + var compileBtnLayout = compileBtnObj.AddComponent(); + compileBtnLayout.preferredWidth = 80; + compileBtnLayout.flexibleWidth = 0; + var compileButton = compileBtnObj.GetComponent