// Copyright (c) 2012-2024 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; } /// /// 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.Location.X && location.X < textBlock.Bounds.Location.X + textBlock.Bounds.Size.X; var containsY = location.Y >= textBlock.Bounds.Location.Y && location.Y < textBlock.Bounds.Location.Y + textBlock.Bounds.Size.Y; 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; SetSelection(left, right); } return base.OnMouseDoubleClick(location, button); } /// 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); Render2D.DrawRectangle(rect, IsFocused ? BorderSelectedColor : BorderColor); // 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; if (textBlocksCount > 0) { // Try to estimate the rough location of the first line float lineHeight = textBlocks[0].Bounds.Height; firstTextBlock = Math.Clamp((int)Math.Floor(viewRect.Y / lineHeight) + 1, 0, textBlocksCount - 1); if (textBlocks[firstTextBlock].Bounds.Top > viewRect.Top) { // Overshoot... for (; firstTextBlock > 0; firstTextBlock--) { ref TextBlock textBlock = ref textBlocks[firstTextBlock]; if (textBlocks[firstTextBlock].Bounds.Top < viewRect.Top) break; } } for (; firstTextBlock < textBlocksCount; firstTextBlock++) { ref TextBlock textBlock = ref textBlocks[firstTextBlock]; if (textBlock.Bounds.Intersects(ref viewRect)) 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 : font.GetCharPosition(_text, selection.StartIndex); var rightEdge = selection.EndIndex >= textBlock.Range.EndIndex ? textBlock.Bounds.UpperRight : font.GetCharPosition(_text, selection.EndIndex); 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; TextRange textBlockRange = new TextRange(0, textBlock.Range.EndIndex - textBlock.Range.StartIndex); string textBlockText = _text.Substring(textBlock.Range.StartIndex, textBlockRange.Length); // 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, textBlockText, ref textBlockRange, color, textBlock.Bounds.Location + textBlock.Style.ShadowOffset, textBlock.Style.CustomMaterial); } // Text color = textBlock.Style.Color; if (!enabled) color *= 0.6f; Render2D.DrawText(font, textBlockText, ref textBlockRange, 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(); } } }