using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; using FlaxEngine; using FlaxEngine.GUI; namespace Game; public class ConsoleContentTextBox : Control { private readonly FontReference Font; protected TextLayoutOptions _layout; public Color BackgroundSelectedColor = Color.Transparent; public float BackgroundSelectedFlashSpeed = 0; public Color BorderColor; public Color BorderSelectedColor = Color.Transparent; public float CaretFlashSpeed = 0; public int DefaultMargin = 1; private float heightMultiplier = 1.0f; [HideInEditor] public ConsoleInputTextBox inputBox; public float LineSpacing = 1.0f; public int ScrollMouseLines = 3; public int ScrollOffset; public bool SelectionAllowed = true; private bool selectionActive; public Color SelectionColor = new Color(0x00, 0x7A, 0xCC); private int selectionEndChar; private int selectionEndLine; private int selectionStartChar; private int selectionStartLine; public Color TextColor = Color.White; public bool CopyOnSelect = true; public TextWrapping Wrapping; public ConsoleContentTextBox() { } public ConsoleContentTextBox(FontReference font, ConsoleInputTextBox inputBox, float x, float y, float width, float height) : base(x, y, width, height) { this.inputBox = inputBox; Height = height; Font = font; _layout = TextLayoutOptions.Default; _layout.VerticalAlignment = TextAlignment.Near; _layout.TextWrapping = TextWrapping.WrapChars; _layout.Bounds = new Rectangle(DefaultMargin, 1, Width - 2 * DefaultMargin, Height - 2); //IsMultiline = true; //IsReadOnly = true; //CaretColor = new Color(0f, 0f, 0f, 0f); AutoFocus = false; } public override void OnDestroy() { base.OnDestroy(); EndMouseCapture(); } public int FontHeight => Font.GetFont().Height; public float HeightMultiplier { get => heightMultiplier; set { heightMultiplier = value; UpdateHeight(); } } public bool HasSelection => !(selectionStartLine == selectionEndLine && selectionStartChar == selectionEndChar); private int GetFontCharacterWidth() { Font font = Font?.GetFont(); if (!font) return 0; return (int)font.MeasureText("a").X; // hacky, but works for fixed-size fonts... } public int GetFontHeight() { Font font = Font?.GetFont(); if (font == null) return (int)Height; return (int)Mathf.Round(LineSpacing * (font.Height / Platform.DpiScale) * Scale.Y); } private int GetHeightInLines() { Font font = Font?.GetFont(); if (!font) return 0; return (int)(Height / (font.Height / Platform.DpiScale)); // number of fully visible lines } protected override void OnParentChangedInternal() { base.OnParentChangedInternal(); if (Parent != null) OnParentResized(); } public override void OnParentResized() { UpdateHeight(); base.OnParentResized(); } private void UpdateHeight() { if (Parent != null && Parent.Parent != null) Height = Parent.Parent.Size.Y * HeightMultiplier - GetFontHeight(); } private void CalculateVisibleLines(ReadOnlySpan lines, out int firstVisibleLine, out int lastVisibleLine, out LineInfo[] wrappedLines) { wrappedLines = null; firstVisibleLine = 0; lastVisibleLine = 0; Font font = Font.GetFont(); if (!font) return; float fontWidth = GetFontCharacterWidth(); int lineMaxChars = (int)(Width / fontWidth); int lineMaxLines = GetHeightInLines(); int numLines = 0; var lineInfos = new List(lineMaxLines + 1); int linesSkipped = 0; //foreach (string line in lines.Reverse()) int lineIndex = lines.Length - 1; for (; lineIndex >= 0; lineIndex--) { //lineIndex--; if (linesSkipped < ScrollOffset) { linesSkipped++; continue; } string line = lines[lineIndex]; int numChars = 0; int startIndex = lineInfos.Count; while (numChars < line.Length) { LineInfo li = new LineInfo(); li.lineIndex = lineIndex; li.lineOffset = numChars; li.lineLength = Math.Min(line.Length - numChars, lineMaxChars); lineInfos.Add(li); numChars += lineMaxChars; } if (lineInfos.Count - startIndex > 1) lineInfos.Reverse(startIndex, lineInfos.Count - startIndex); numLines++; if (lineInfos.Count > lineMaxLines) break; } lineInfos.Reverse(); wrappedLines = lineInfos.ToArray(); //return lines[lines.Count - numLines .. lines.Count]; // C# 8.0... lastVisibleLine = lineIndex; firstVisibleLine = lastVisibleLine - numLines; } public override void Draw() { // Cache data Rectangle rect = new Rectangle(Float2.Zero, Size); Font font = Font.GetFont(); if (!font) return; // Background Profiler.BeginEvent("ConsoleContentTextBoxDraw_Background"); Color backColor = BackgroundColor; if (IsMouseOver) backColor = BackgroundSelectedColor; if (backColor.A > 0.0f) Render2D.FillRectangle(rect, backColor); Color borderColor = IsFocused ? BorderSelectedColor : BorderColor; if (borderColor.A > 0.0f) Render2D.DrawRectangle(rect, borderColor); Profiler.EndEvent(); Profiler.BeginEvent("ConsoleContentTextBoxDraw_FetchLines"); var lines = Console.Lines; Profiler.EndEvent(); if (lines.Length > 0) { Profiler.BeginEvent("ConsoleContentTextBoxDraw_Lines"); // Apply view offset and clip mask Rectangle textClipRectangle = new Rectangle(1, 1, Width - 2, Height - 2); Render2D.PushClip(textClipRectangle); Profiler.BeginEvent("ConsoleContentTextBoxDraw_CalcVisLines"); // Make sure lengthy lines are split CalculateVisibleLines(lines, out int startLine, out int lastLine, out var wrappedLines); Profiler.EndEvent(); float lineHeight = font.Height / Platform.DpiScale; float accumHeight = wrappedLines.Length * lineHeight; // selection in line-space, wrapping ignored int selectionLeftLine = selectionStartLine; int selectionLeftChar = selectionStartChar; int selectionRightLine = selectionEndLine; int selectionRightChar = selectionEndChar; if (selectionLeftLine > selectionRightLine || (selectionLeftLine == selectionRightLine && selectionLeftChar > selectionRightChar)) { selectionLeftLine = selectionEndLine; selectionLeftChar = selectionEndChar; selectionRightLine = selectionStartLine; selectionRightChar = selectionStartChar; } // render selection if (selectionActive) { Profiler.BeginEvent("ConsoleContentTextBoxDraw_Selection"); //float alpha = Mathf.Min(1.0f, Mathf.Cos(_animateTime * BackgroundSelectedFlashSpeed) * 0.5f + 1.3f); //alpha = alpha * alpha; Color selectionColor = SelectionColor; // * alpha; TextLayoutOptions layout = _layout; layout.Bounds = rect; layout.Bounds.Y -= accumHeight - Height; //for (int i = startLine; i < lastLine; i++) foreach (LineInfo li in wrappedLines) { int lineIndex = li.lineIndex; string fullLine = lines[lineIndex]; string line = fullLine.Substring(li.lineOffset, li.lineLength); int leftChar = selectionLeftChar; int rightChar = selectionRightChar; Rectangle selectionRect = new Rectangle(layout.Bounds.X, layout.Bounds.Y, 0f, 0f); // apply selection if (lineIndex >= selectionLeftLine && lineIndex <= selectionRightLine) { if (lineIndex > selectionLeftLine && lineIndex < selectionRightLine) { // whole line is selected Float2 lineSize = font.MeasureText(line); selectionRect.Width = lineSize.X; selectionRect.Height = lineSize.Y; } else if (lineIndex == selectionLeftLine) { if (lineIndex < selectionRightLine) { // right side of the line is selected Float2 leftSize = font.MeasureText(fullLine.Substring(0, leftChar)); Float2 rightSize = font.MeasureText(fullLine.Substring(leftChar)); selectionRect.X += leftSize.X; selectionRect.Width = rightSize.X; selectionRect.Height = rightSize.Y; //int diff = line.Length - selectionLeftChar; //line = line.Substring(0, selectionLeftChar) + (diff > 0 ? new string('X', diff) : ""); } else if (lineIndex == selectionRightLine && leftChar != rightChar) { // selecting middle of the one line Float2 lineSize = font.MeasureText(fullLine); Float2 leftSize = font.MeasureText(fullLine.Substring(0, leftChar)); Float2 midSize = font.MeasureText(fullLine.Substring(leftChar, rightChar - leftChar)); selectionRect.X += leftSize.X; selectionRect.Width = midSize.X; selectionRect.Height = lineSize.Y; //int diff = selectionRightChar - selectionLeftChar; //line = line.Substring(0, selectionLeftChar) + (diff > 0 ? new string('X', diff) : "") + line.Substring(selectionRightChar); } } else if (lineIndex == selectionRightLine) { // left side of the line is selected Float2 leftSize = font.MeasureText(fullLine.Substring(0, rightChar)); selectionRect.Width = leftSize.X; selectionRect.Height = leftSize.Y; //line = (selectionRightChar > 0 ? new string('X', selectionRightChar) : "") + line.Substring(selectionRightChar); } } Render2D.FillRectangle(selectionRect, selectionColor); layout.Bounds.Y += lineHeight; } Profiler.EndEvent(); } // render lines { Profiler.BeginEvent("ConsoleContentTextBoxDraw_Lines_Render"); TextLayoutOptions layout = _layout; layout.Bounds = rect; layout.Bounds.Y -= accumHeight - Height; foreach (LineInfo li in wrappedLines) { int lineIndex = li.lineIndex; string line = lines[lineIndex].content.Substring(li.lineOffset, li.lineLength); Render2D.DrawText(font, line, TextColor, ref layout); layout.Bounds.Y += lineHeight; } Profiler.EndEvent(); } /*if (CaretPosition > -1) { var prefixSize = TextPrefix != "" ? font.MeasureText(TextPrefix) : new Float2(); var caretBounds = CaretBounds; caretBounds.X += prefixSize.X; float alpha = Mathf.Saturate(Mathf.Cos(_animateTime * CaretFlashSpeed) * 0.5f + 0.7f); alpha = alpha * alpha * alpha * alpha * alpha * alpha; Render2D.FillRectangle(caretBounds, CaretColor * alpha); }*/ Render2D.PopClip(); Profiler.EndEvent(); } } private void OnSelectingBegin() { if (selectionActive) return; selectionActive = true; StartMouseCapture(); } private void OnSelectingEnd() { if (!selectionActive) return; selectionActive = false; EndMouseCapture(); } public bool HitTestText(Float2 location, out int hitLine, out int hitChar) { hitLine = 0; hitChar = 0; Font font = Font.GetFont(); if (font == null) return false; var lines = Console.Lines; CalculateVisibleLines(lines, out int startLine, out int lastLine, out var wrappedLines); TextLayoutOptions layout = _layout; layout.Bounds = new Rectangle(0, 0, Size); float lineHeightNormalized = font.Height; float lineHeight = lineHeightNormalized / Platform.DpiScale; float visibleHeight = wrappedLines.Length * lineHeight; float top = (layout.Bounds.Bottom - visibleHeight) / Platform.DpiScale; // UI coordinate space remains normalized int lineMaxLines = (int)(Height / lineHeight); int hiddenLines = 0; if (wrappedLines.Length > lineMaxLines) hiddenLines = wrappedLines.Length - lineMaxLines; //if (top < layout.Bounds.Top) // hiddenLines = (int)Math.Ceiling((layout.Bounds.Top - top) / (float)lineHeight); int hitWrappedLine = (int)((location.Y - top) / lineHeight); //+ hiddenLines; if (hitWrappedLine < 0 || hitWrappedLine >= wrappedLines.Length) return false; hitLine = wrappedLines[hitWrappedLine].lineIndex; string line = lines[hitLine].content.Substring(wrappedLines[hitWrappedLine].lineOffset, wrappedLines[hitWrappedLine].lineLength); layout.Bounds.Y = top + hitWrappedLine * lineHeight; layout.Bounds.Height = top + 9999; //(visibleHeight / Platform.DpiScale); /*if (layout.Bounds.Y < 0) { layout.Bounds.Y = 1; }*/ hitChar = font.HitTestText(line, location, ref layout); hitChar += wrappedLines[hitWrappedLine].lineOffset; //FlaxEngine.Debug.Log(string.Format("hit line {0}/{1}, char {2}", hitWrappedLine, wrappedLines.Length, hitChar)); return true; } /*public int CharIndexAtPoint(ref Float2 location) { return HitTestText(location + _viewOffset); }*/ public override bool OnKeyDown(KeyboardKeys key) { if (!SelectionAllowed) return false; bool shiftDown = Root.GetKey(KeyboardKeys.Shift); bool ctrlDown = Root.GetKey(KeyboardKeys.Control); if (!CopyOnSelect) { // This is not working for some reason, the event is never called while selecting text if ((shiftDown && key == KeyboardKeys.Delete) || (ctrlDown && key == KeyboardKeys.Insert) || (ctrlDown && key == KeyboardKeys.C) || (ctrlDown && key == KeyboardKeys.X)) { Copy(); return true; } } if (key == KeyboardKeys.PageUp) { ScrollOffset += GetHeightInLines() / 2; // should count the wrapped line count here over Console.Lines.Count //var maxOffset = Console.Lines.Count - GetHeightInLines(); int maxOffset = Console.Lines.Length - 1; if (ScrollOffset > maxOffset) ScrollOffset = maxOffset; return true; } else if (key == KeyboardKeys.PageDown) { ScrollOffset -= GetHeightInLines() / 2; if (ScrollOffset < 0) ScrollOffset = 0; return true; } //else if (ctrlDown && key == KeyboardKeys.A) // SelectAll(); if (!base.OnKeyDown(key)) { inputBox.Focus(); return inputBox.OnKeyDown(key); } return true; } public override bool OnMouseWheel(Float2 location, float delta) { if (!SelectionAllowed) return false; if (delta < 0) { ScrollOffset -= ScrollMouseLines; if (ScrollOffset < 0) ScrollOffset = 0; } else if (delta > 0) { ScrollOffset += ScrollMouseLines; int maxOffset = Console.Lines.Length - GetHeightInLines(); if (ScrollOffset > maxOffset) ScrollOffset = maxOffset; } return false; } public override bool OnMouseDown(Float2 location, MouseButton button) { if (!SelectionAllowed) return false; if (!Bounds.Contains(location)) { OnSelectingEnd(); return false; } bool ret = false; if (/*button == MouseButton.Left &&*/ !IsFocused) { //inputBox.Focus(); //ret = true; } if (button == MouseButton.Left && Console.Lines.Length > 0) { bool selectionStarted = !selectionActive; //Focus(); OnSelectingBegin(); //FlaxEngine.Debug.Log("mousedown, started: " + selectionStarted.ToString()); if (HitTestText(location, out int hitLine, out int hitChar)) { selectionStartLine = hitLine; selectionStartChar = hitChar; selectionEndLine = hitLine; selectionEndChar = hitChar; //FlaxEngine.Debug.Log(string.Format("start line {0} char {1}", hitLine, hitChar)); } // Select range with shift /*if (_selectionStart != -1 && RootWindow.GetKey(KeyboardKeys.Shift) && SelectionLength == 0) { if (hitPos < _selectionStart) SetSelection(hitPos, _selectionStart); else SetSelection(_selectionStart, hitPos); } else { SetSelection(hitPos); }*/ return true; } return ret; } public override void OnMouseMove(Float2 location) { if (!SelectionAllowed) return; if (selectionActive) if (HitTestText(location, out int hitLine, out int hitChar)) { selectionEndLine = hitLine; selectionEndChar = hitChar; if (CopyOnSelect) Copy(); //FlaxEngine.Debug.Log(string.Format("end line {0} char {1}", hitLine, hitChar)); } } /// public override bool OnMouseUp(Float2 location, MouseButton button) { if (!SelectionAllowed) return false; if (button == MouseButton.Left) OnSelectingEnd(); if (Bounds.Contains(location)) Focus(inputBox); return true; } public override void OnMouseLeave() { base.OnMouseLeave(); if (!SelectionAllowed) return; if (selectionActive) { OnSelectingEnd(); Focus(inputBox); } } protected void Copy() { if (!selectionActive) return; // selection in line-space, wrapping ignored int selectionLeftLine = selectionStartLine; int selectionLeftChar = selectionStartChar; int selectionRightLine = selectionEndLine; int selectionRightChar = selectionEndChar; if (selectionLeftLine > selectionRightLine || (selectionLeftLine == selectionRightLine && selectionLeftChar > selectionRightChar)) { selectionLeftLine = selectionEndLine; selectionLeftChar = selectionEndChar; selectionRightLine = selectionStartLine; selectionRightChar = selectionStartChar; } var lines = Console.Lines; CalculateVisibleLines(lines, out int startLine, out int lastLine, out var wrappedLines); StringBuilder selectedText = new StringBuilder(); int lastLineIndex = -1; foreach (LineInfo li in wrappedLines) { int lineIndex = li.lineIndex; if (lineIndex < selectionLeftLine || lineIndex > selectionRightLine) continue; if (lastLineIndex != lineIndex && lastLineIndex != -1) selectedText.AppendLine(); lastLineIndex = lineIndex; string fullLine = lines[lineIndex]; string line = fullLine.Substring(li.lineOffset, li.lineLength); int leftChar = selectionLeftChar; int rightChar = selectionRightChar; if (lineIndex >= selectionLeftLine && lineIndex <= selectionRightLine) { if (lineIndex > selectionLeftLine && lineIndex < selectionRightLine) { // whole line is selected selectedText.Append(line); } else if (lineIndex == selectionLeftLine) { if (lineIndex < selectionRightLine) // right side of the line is selected selectedText.Append(fullLine.Substring(leftChar)); else if (lineIndex == selectionRightLine && leftChar != rightChar) // selecting middle of the one line selectedText.Append(fullLine.Substring(leftChar, rightChar - leftChar)); } else if (lineIndex == selectionRightLine) { // left side of the line is selected selectedText.Append(fullLine.Substring(0, rightChar)); } } } if (selectedText.Length > 0) Clipboard.Text = selectedText.ToString(); } private struct LineInfo { public int lineIndex; public int lineOffset; public int lineLength; } }