From 26d508684b601e73aed7750adbec79b0a672ea35 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 20 Sep 2026 00:22:04 +0200 Subject: [PATCH] Add Wrap Support on Rich Text Box Merges #4196 --- .../UI/GUI/Common/RichTextBox.Parsing.cs | 120 +++++++++++++++--- Source/Engine/UI/GUI/Common/RichTextBox.cs | 41 +++++- .../Engine/UI/GUI/Common/RichTextBoxBase.cs | 20 +-- 3 files changed, 154 insertions(+), 27 deletions(-) diff --git a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs index bb6ee22a5..08eda39ee 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBox.Parsing.cs @@ -225,37 +225,123 @@ namespace FlaxEngine.GUI var lines = font.ProcessText(_text, ref textBlock.Range); if (lines == null || lines.Length == 0) return; + var wrapWidth = _wrapping != TextWrapping.NoWrap ? Width : -1.0f; + for (int i = 0; i < lines.Length; i++) { ref var line = ref lines[i]; - textBlock.Range = new TextRange + var lineRange = new TextRange { StartIndex = start + line.FirstCharIndex, EndIndex = start + line.LastCharIndex + 1, }; + if (i != 0) { context.Caret.X = 0; - OnLineAdded(ref context, textBlock.Range.StartIndex - 1); + OnLineAdded(ref context, lineRange.StartIndex - 1); } - textBlock.Bounds = new Rectangle(context.Caret, line.Size); - textBlock.Bounds.X += line.Location.X; - context.AddTextBlock(ref textBlock); - } - - // Update the caret location - ref var lastLine = ref lines[lines.Length - 1]; - if (lines.Length == 1) - { - context.Caret.X += lastLine.Size.X; - } - else - { - context.Caret.X = lastLine.Size.X; + if (wrapWidth > 0 && context.Caret.X + line.Size.X > wrapWidth) + { + // Line overflows the available width - split it into multiple wrapped lines + AddWrappedTextBlocks(ref context, ref textBlock, font, lineRange, wrapWidth, _wrapping); + } + else + { + // Add block + textBlock.Range = lineRange; + textBlock.Bounds = new Rectangle(context.Caret, line.Size); + textBlock.Bounds.X += line.Location.X; + context.AddTextBlock(ref textBlock); + context.Caret.X += line.Size.X; + } } } + /// + /// Splits the given text range into multiple text blocks (lines) so it fits within the available wrapping width, correctly continuing from the current caret position (eg. when a differently-styled text run follows on the same line). + /// + /// The parsing context. + /// The template text block (style is reused, range and bounds get overriden per produced block). + /// The font used to measure and render the text. + /// The text range to wrap (single logical line - no explicit newlines inside). + /// The maximum available width (in control-space) that a single visual line can use. + /// The wrapping mode - either whole-word (breaks only at whitespace) or per-character (can break in the middle of a word). + private void AddWrappedTextBlocks(ref ParsingContext context, ref TextBlock textBlock, Font font, TextRange range, float wrapWidth, TextWrapping wrapping) + { + int segmentStart = range.StartIndex; + float segmentWidth = 0.0f; + int pos = range.StartIndex; + while (pos < range.EndIndex) + { + // Consume the next "chunk" - a single character for WrapChars, or a whole word (plus trailing whitespace) for WrapWords + int chunkStart = pos; + if (wrapping == TextWrapping.WrapChars) + { + pos++; + } + else + { + while (pos < range.EndIndex && !char.IsWhiteSpace(_text[pos])) + pos++; + while (pos < range.EndIndex && char.IsWhiteSpace(_text[pos])) + pos++; + } + + var chunkRange = new TextRange { StartIndex = chunkStart, EndIndex = pos }; + var chunkWidth = font.MeasureText(_text, ref chunkRange).X; + + if ((segmentWidth > 0.0f || context.Caret.X > 0.0f) && context.Caret.X + segmentWidth + chunkWidth > wrapWidth) + { + // The next chunk no longer fits - emit the accumulated segment (if any) and start a new line + if (segmentStart < chunkStart) + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, chunkStart); + context.Caret.X = 0; + OnLineAdded(ref context, chunkStart - 1); + segmentStart = chunkStart; + segmentWidth = 0.0f; + } + segmentWidth += chunkWidth; + + // For Wrap Words mode: A single word wider than the whole available width can't be split further, so force it onto its own line + if (wrapping == TextWrapping.WrapWords && context.Caret.X <= 0.0f && segmentStart == chunkStart && segmentWidth > wrapWidth && pos < range.EndIndex) + { + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, pos); + context.Caret.X = 0; + OnLineAdded(ref context, pos - 1); + segmentStart = pos; + segmentWidth = 0.0f; + } + } + if (segmentStart < range.EndIndex) + AddWrappedTextBlock(ref context, ref textBlock, font, segmentStart, range.EndIndex); + } + + /// + /// Adds a single text block to the control, using the current caret position as the origin and moving the caret forward by the width of the text block. + /// + /// The parsing context. + /// The text block to add. + /// The font to use for measurement. + /// The start index of the text range. + /// The end index of the text range. + private void AddWrappedTextBlock(ref ParsingContext context, ref TextBlock textBlock, Font font, int start, int end) + { + var range = new TextRange { StartIndex = start, EndIndex = end }; + var size = font.MeasureText(_text, ref range); + textBlock.Range = range; + textBlock.Bounds = new Rectangle(context.Caret, size); + context.AddTextBlock(ref textBlock); + context.Caret.X += size.X; + } + + /// + /// Called when a new line is added (eg. after a newline character or when the text overflows the available width). + /// It organizes the text blocks within the line and moves the caret to the next line. + /// + /// The parsing context. + /// The index of the last character in the line. private void OnLineAdded(ref ParsingContext context, int lineEnd) { // Calculate size of the line @@ -333,7 +419,7 @@ namespace FlaxEngine.GUI // Move to the next line context.LineStartCharacterIndex = lineEnd + 1; context.LineStartTextBlockIndex = _textBlocks.Count; - context.Caret.Y += lineSize.Y; + context.Caret.Y += lineSize.Y * BaseLinesGapScale; } } } diff --git a/Source/Engine/UI/GUI/Common/RichTextBox.cs b/Source/Engine/UI/GUI/Common/RichTextBox.cs index f7726bf56..314af4ed8 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBox.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBox.cs @@ -1,6 +1,7 @@ // Copyright (c) Wojciech Figat. All rights reserved. using System.Collections.Generic; +using System.ComponentModel; namespace FlaxEngine.GUI { @@ -11,6 +12,8 @@ namespace FlaxEngine.GUI public partial class RichTextBox : RichTextBoxBase { private TextBlockStyle _textStyle; + private TextWrapping _wrapping = TextWrapping.NoWrap; + private float _baseLinesGapScale = 1.0f; /// /// The default text style applied to the whole text. @@ -26,16 +29,50 @@ namespace FlaxEngine.GUI } } + /// + /// Gets or sets the text wrapping within the control bounds. + /// + [DefaultValue(TextWrapping.NoWrap)] + [EditorOrder(30), Tooltip("The text wrapping within the control bounds.")] + public TextWrapping Wrapping + { + get => _wrapping; + set + { + if (_wrapping == value) + return; + _wrapping = value; + UpdateTextBlocks(); + } + } + + /// + /// Gets or sets the gap between lines when wrapping and more than a single line is displayed. + /// + [DefaultValue(1.0f)] + [EditorOrder(40), Tooltip("The gap between lines when wrapping and more than a single line is displayed."), Limit(0f, 100.0f, 0.01f)] + public float BaseLinesGapScale + { + get => _baseLinesGapScale; + set + { + if (Mathf.NearEqual(_baseLinesGapScale, value)) + return; + _baseLinesGapScale = value; + UpdateTextBlocks(); + } + } + /// /// The collection of custom text styles to apply (named). /// - [EditorOrder(30)] + [EditorOrder(50)] public Dictionary Styles = new Dictionary(); /// /// The collection of custom images/sprites that can be inlined in text (named). /// - [EditorOrder(40)] + [EditorOrder(60)] public Dictionary Images = new Dictionary(); /// diff --git a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs index b83a89943..41927541e 100644 --- a/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs +++ b/Source/Engine/UI/GUI/Common/RichTextBoxBase.cs @@ -100,15 +100,19 @@ namespace FlaxEngine.GUI } // Handle case when index is outside all text ranges - if (index >= 0 && blockCount > 0 && index <= textBlocksSpan[0].Range.StartIndex) + if (index >= 0 && blockCount > 0) { - result = textBlocksSpan[0]; - return true; - } - if (index >= 0 && blockCount > 0 && index >= textBlocksSpan[blockCount - 1].Range.StartIndex) - { - result = textBlocksSpan[blockCount - 1]; - return true; + if (index <= textBlocksSpan[0].Range.StartIndex) + { + result = textBlocksSpan[0]; + return true; + } + + if (index >= textBlocksSpan[blockCount - 1].Range.StartIndex) + { + result = textBlocksSpan[blockCount - 1]; + return true; + } } // If no text block is found