// Copyright (c) Wojciech Figat. All rights reserved. using System; using System.Collections.Generic; using System.Runtime.InteropServices; namespace FlaxEngine.GUI { /// /// Base class for all rich text box controls which can gather text input from the user and present text in highly formatted and stylized way. /// public abstract class RichTextBoxBase : TextBoxBase { /// /// The delegate for text blocks processing. /// /// The text. /// The output text blocks. Given list is not-null and cleared before. public delegate void ParseTextBlocksDelegate(string text, List textBlocks); /// /// The text blocks. /// protected List _textBlocks = new List(); /// /// The custom callback for parsing text blocks. /// [HideInEditor] public ParseTextBlocksDelegate ParseTextBlocks; /// /// Initializes a new instance of the class. /// protected RichTextBoxBase() { IsMultiline = true; } /// /// Gets the text block of the character at the given index. /// /// The character index. /// The result text block descriptor. /// True if got text block, otherwise false. public bool GetTextBlock(int index, out TextBlock result) { var textBlocks = CollectionsMarshal.AsSpan(_textBlocks); var count = _textBlocks.Count; for (int i = 0; i < count; i++) { ref TextBlock textBlock = ref textBlocks[i]; if (textBlock.Range.Contains(index)) { result = textBlock; return true; } } result = new TextBlock(); return false; } /// /// Gets the text block or the nearest text block of the character at the given index. /// /// The character index. /// The result text block descriptor. /// If true, when the index is between two text blocks, it will return the next block. /// True if a text block is found, otherwise false. public bool GetNearestTextBlock(int index, out TextBlock result, bool snapToNext = false) { var textBlocksSpan = CollectionsMarshal.AsSpan(_textBlocks); int blockCount = _textBlocks.Count; for (int i = 0; i < blockCount; i++) { ref TextBlock currentBlock = ref textBlocksSpan[i]; if (currentBlock.Range.Contains(index)) { result = currentBlock; return true; } if (i < blockCount - 1) { ref TextBlock nextBlock = ref textBlocksSpan[i + 1]; var containsY = nextBlock.Bounds.Y >= currentBlock.Bounds.Top && nextBlock.Bounds.Y < currentBlock.Bounds.Bottom; if (index >= currentBlock.Range.EndIndex && index < nextBlock.Range.StartIndex && containsY) { result = snapToNext ? nextBlock : currentBlock; return true; } } } // Handle case when index is outside all text ranges if (index >= 0 && blockCount > 0 && index <= textBlocksSpan[0].Range.StartIndex) { result = textBlocksSpan[0]; return true; } if (index >= 0 && blockCount > 0 && index >= textBlocksSpan[blockCount - 1].Range.StartIndex) { result = textBlocksSpan[blockCount - 1]; return true; } // If no text block is found result = new TextBlock(); return false; } /// /// Updates the text blocks. /// public virtual void UpdateTextBlocks() { Profiler.BeginEvent("RichTextBoxBase.UpdateTextBlocks"); _textBlocks.Clear(); if (_text.Length != 0) { OnParseTextBlocks(); } Profiler.EndEvent(); } /// /// Called when text blocks needs to be updated from the current text. /// protected virtual void OnParseTextBlocks() { ParseTextBlocks?.Invoke(_text, _textBlocks); } /// protected override void OnTextChanged() { UpdateTextBlocks(); base.OnTextChanged(); } /// public override Float2 GetTextSize() { var count = _textBlocks.Count; var textBlocks = CollectionsMarshal.AsSpan(_textBlocks); var max = Float2.Zero; for (int i = 0; i < count; i++) { ref TextBlock textBlock = ref textBlocks[i]; max = Float2.Max(max, textBlock.Bounds.BottomRight); } return max; } /// public override Float2 GetCharPosition(int index, out float height) { var count = _textBlocks.Count; var textBlocks = CollectionsMarshal.AsSpan(_textBlocks); // Check if text is empty if (count == 0) { height = 0; return Float2.Zero; } // Check if get first character position if (index <= 0) { ref TextBlock textBlock = ref textBlocks[0]; var font = textBlock.Style.Font.GetFont(); if (font) { height = font.Height / DpiScale; return textBlock.Bounds.UpperLeft; } } // Check if get last character position if (index >= _text.Length) { ref TextBlock textBlock = ref textBlocks[count - 1]; var font = textBlock.Style.Font.GetFont(); if (font) { height = font.Height / DpiScale; return textBlock.Bounds.UpperRight; } } // Test any text block for (int i = 0; i < count; i++) { ref TextBlock textBlock = ref textBlocks[i]; if (textBlock.Range.Contains(index)) { var font = textBlock.Style.Font.GetFont(); if (!font) break; height = font.Height / DpiScale; return textBlock.Bounds.Location + font.GetCharPosition(_text, ref textBlock.Range, index - textBlock.Range.StartIndex); } } // Test any character between block that was not included in any of them (eg. newline character) for (int i = count - 1; i >= 0; i--) { ref TextBlock textBlock = ref textBlocks[i]; if (index >= textBlock.Range.EndIndex) { var font = textBlock.Style.Font.GetFont(); if (!font) break; height = font.Height / DpiScale; return textBlock.Bounds.UpperRight; } } height = 0; return Float2.Zero; } /// public override int HitTestText(Float2 location) { location = Float2.Clamp(location, Float2.Zero, _textSize); var textBlocks = CollectionsMarshal.AsSpan(_textBlocks); var count = _textBlocks.Count; for (int i = 0; i < count; i++) { ref TextBlock textBlock = ref textBlocks[i]; var containsX = location.X >= textBlock.Bounds.Left && location.X < textBlock.Bounds.Right; var containsY = location.Y >= textBlock.Bounds.Top && location.Y < textBlock.Bounds.Bottom; if (containsY && (containsX || (i + 1 < count && textBlocks[i + 1].Bounds.Location.Y > textBlock.Bounds.Location.Y + 1.0f))) { var font = textBlock.Style.Font.GetFont(); if (!font && textBlock.Range.Length > 0) break; return font.HitTestText(_text, ref textBlock.Range, location - textBlock.Bounds.Location) + textBlock.Range.StartIndex; } } return _text.Length; } /// public override bool OnMouseDoubleClick(Float2 location, MouseButton button) { // Select the word under the mouse int textLength = TextLength; if (textLength != 0 && IsSelectable) { var hitPos = CharIndexAtPoint(ref location); int spaceLoc = _text.LastIndexOfAny(Separators, hitPos - 2); var left = spaceLoc == -1 ? 0 : spaceLoc + 1; spaceLoc = _text.IndexOfAny(Separators, Math.Min(hitPos + 1, _text.Length)); var right = spaceLoc == -1 ? textLength : spaceLoc; Deselect(); SetSelection(left, right); } return base.OnMouseDoubleClick(location, button); } /// protected override void SetSelection(int start, int end, bool withScroll = true) { int snappedStart = start; int snappedEnd = end; // Snapping selection between text blocks if (start != -1 && end != -1) { bool movingBack = (_selectionStart != -1 && _selectionEnd != -1) && (end < _selectionEnd || start < _selectionStart); if (GetNearestTextBlock(start, out TextBlock startTextBlock, !movingBack)) { snappedStart = startTextBlock.Range.Contains(start) ? start : (movingBack ? startTextBlock.Range.EndIndex - 1 : startTextBlock.Range.StartIndex); snappedStart = movingBack ? Math.Min(start, snappedStart) : Math.Max(start, snappedStart); // Don't snap if selection is right in the end of the text if (start == _text.Length) snappedStart = _text.Length; } if (GetNearestTextBlock(end, out TextBlock endTextBlock, !movingBack)) { snappedEnd = endTextBlock.Range.Contains(end) ? end : (movingBack ? endTextBlock.Range.EndIndex - 1 : endTextBlock.Range.StartIndex); snappedEnd = movingBack ? Math.Min(end, snappedEnd) : Math.Max(end, snappedEnd); // Don't snap if selection is right in the end of the text if (end == _text.Length) snappedEnd = _text.Length; } } base.SetSelection(snappedStart, snappedEnd, withScroll); } /// public override void DrawSelf() { // Cache data var rect = new Rectangle(Float2.Zero, Size); bool enabled = EnabledInHierarchy; // Background Color backColor = BackgroundColor; if (IsMouseOver || IsNavFocused) backColor = BackgroundSelectedColor; Render2D.FillRectangle(rect, backColor); if (HasBorder) Render2D.DrawRectangle(rect, IsFocused ? BorderSelectedColor : BorderColor, BorderThickness); // Apply view offset and clip mask if (ClipText) Render2D.PushClip(TextClipRectangle); bool useViewOffset = !_viewOffset.IsZero; if (useViewOffset) Render2D.PushTransform(Matrix3x3.Translation2D(-_viewOffset)); // Calculate text blocks for drawing var textBlocks = CollectionsMarshal.AsSpan(_textBlocks); var textBlocksCount = _textBlocks?.Count ?? 0; var hasSelection = HasSelection; var selection = new TextRange(SelectionLeft, SelectionRight); var viewRect = new Rectangle(_viewOffset, Size).MakeExpanded(10.0f); var firstTextBlock = textBlocksCount; for (int i = 0; i < textBlocksCount; i++) { ref TextBlock textBlock = ref textBlocks[i]; if (textBlock.Bounds.Intersects(ref viewRect)) { firstTextBlock = i; break; } } var endTextBlock = Mathf.Min(firstTextBlock + 1, textBlocksCount); for (int i = textBlocksCount - 1; i > firstTextBlock; i--) { ref TextBlock textBlock = ref textBlocks[i]; if (textBlock.Bounds.Intersects(ref viewRect)) { endTextBlock = i + 1; break; } } // Draw background for (int i = firstTextBlock; i < endTextBlock; i++) { ref TextBlock textBlock = ref textBlocks[i]; // Background if (textBlock.Style.BackgroundBrush != null) { textBlock.Style.BackgroundBrush.Draw(textBlock.Bounds, textBlock.Style.Color); } // Pick font var font = textBlock.Style.Font.GetFont(); if (!font) continue; // Selection if (hasSelection && textBlock.Style.BackgroundSelectedBrush != null && textBlock.Range.Intersect(ref selection)) { var leftEdge = selection.StartIndex <= textBlock.Range.StartIndex ? textBlock.Bounds.UpperLeft : GetCharPosition(selection.StartIndex, out _); var rightEdge = selection.EndIndex >= textBlock.Range.EndIndex ? textBlock.Bounds.UpperRight : GetCharPosition(selection.EndIndex, out _); float height = font.Height; float alpha = Mathf.Min(1.0f, Mathf.Cos(_animateTime * BackgroundSelectedFlashSpeed) * 0.5f + 1.3f); alpha *= alpha; Color selectionColor = Color.White * alpha; Rectangle selectionRect = new Rectangle(leftEdge.X, leftEdge.Y, rightEdge.X - leftEdge.X, height); textBlock.Style.BackgroundSelectedBrush.Draw(selectionRect, selectionColor); } } // Draw text for (int i = firstTextBlock; i < endTextBlock; i++) { ref TextBlock textBlock = ref textBlocks[i]; // Pick font var font = textBlock.Style.Font.GetFont(); if (!font) continue; // Shadow Color color; if (!textBlock.Style.ShadowOffset.IsZero && textBlock.Style.ShadowColor != Color.Transparent) { color = textBlock.Style.ShadowColor; if (!enabled) color *= 0.6f; Render2D.DrawText(font, _text, ref textBlock.Range, color, textBlock.Bounds.Location + textBlock.Style.ShadowOffset, textBlock.Style.CustomMaterial); } // Text color = textBlock.Style.Color; if (!enabled) color *= 0.6f; Render2D.DrawText(font, _text, ref textBlock.Range, color, textBlock.Bounds.Location, textBlock.Style.CustomMaterial); } // Draw underline for (int i = firstTextBlock; i < endTextBlock; i++) { ref TextBlock textBlock = ref textBlocks[i]; // Pick font var font = textBlock.Style.Font.GetFont(); if (!font) continue; // Underline if (textBlock.Style.UnderlineBrush != null) { var underLineHeight = 2.0f; var height = font.Height; var underlineRect = new Rectangle(textBlock.Bounds.Location.X, textBlock.Bounds.Location.Y + height - underLineHeight * 0.5f, textBlock.Bounds.Width, underLineHeight); textBlock.Style.UnderlineBrush.Draw(underlineRect, textBlock.Style.Color); } } // Caret if (IsFocused && CaretPosition > -1) { float alpha = Mathf.Saturate(Mathf.Cos(_animateTime * CaretFlashSpeed) * 0.5f + 0.7f); alpha = alpha * alpha * alpha * alpha * alpha * alpha; Render2D.FillRectangle(CaretBounds, CaretColor * alpha); } // Restore rendering state if (useViewOffset) Render2D.PopTransform(); if (ClipText) Render2D.PopClip(); } /// public override void OnDestroy() { _textBlocks.Clear(); _textBlocks = null; base.OnDestroy(); } } }