diff --git a/Blish HUD/Controls/MultilineTextBox.cs b/Blish HUD/Controls/MultilineTextBox.cs index 45127e39c..b17ca9363 100644 --- a/Blish HUD/Controls/MultilineTextBox.cs +++ b/Blish HUD/Controls/MultilineTextBox.cs @@ -14,22 +14,88 @@ public bool HideBackground { set => SetProperty(ref _hideBackground, value); } + protected int[] _displayNewLineIndices = Array.Empty(); + + /// + /// The indices of the added new line characters in the processed + /// . + /// + public int[] DisplayNewLineIndices => _displayNewLineIndices; + + private bool _disableWordWrap; + + /// + /// Determines whether the automatic word-wrap will be disabled. + /// + public bool DisableWordWrap { + get => _disableWordWrap; + set { + if (SetProperty(ref _disableWordWrap, value)) { + RecalculateLayout(); + } + } + } + + private char[] _wrapCharacters; + + /// + /// The characters, that are used to wrap a word, if it does not fit the current line + /// it's on. + /// + public char[] WrapCharacters { + get => _wrapCharacters ?? Array.Empty(); + set { + if (SetProperty(ref _wrapCharacters, value)) { + RecalculateLayout(); + } + } + } + public MultilineTextBox() { _multiline = true; _maxLength = 524288; } + /// + /// Calculates the actual cursor index (in reference to + /// ), if the + /// was calculated using the . + /// + protected int GetCursorIndexFromDisplayIndex(int displayIndex) { + int cursorIndex = displayIndex; + foreach (int displayNewLineIndex in _displayNewLineIndices) { + if (displayNewLineIndex > displayIndex) break; + cursorIndex--; + } + + return cursorIndex; + } + + /// + /// Calculates the display cursor index (in reference to + /// ), if the + /// was calculated using the . + /// + protected int GetDisplayIndexFromCursorIndex(int cursorIndex) { + int displayIndex = cursorIndex; + foreach (int displayNewLineIndex in _displayNewLineIndices) { + if (displayNewLineIndex > displayIndex) break; + displayIndex++; + } + return displayIndex; + } + protected override void MoveLine(int delta) { int newIndex = 0; // if targetLine is < 0, we set cursor index to 0 - string[] lines = _text.Split(NEWLINE); + string[] lines = _displayText.Split(NEWLINE); var cursor = GetSplitIndex(_cursorIndex); int targetLine = cursor.Line + delta; if (targetLine >= lines.Length) { - newIndex = _text.Length; + newIndex = _displayText.Length; } else if (targetLine >= 0) { float cursorLeft = MeasureStringWidth(lines[cursor.Line].Substring(0, cursor.Character)); float minOffset = cursorLeft; @@ -53,20 +119,42 @@ protected override void MoveLine(int delta) { } } + newIndex = GetCursorIndexFromDisplayIndex(newIndex); + UserSetCursorIndex(newIndex); UpdateSelectionIfShiftDown(); } + /// + protected override string ProcessDisplayText(string value) { + return ApplyWordWrap(value); + } + + /// + /// Applies word-wrap to the . + /// + protected string ApplyWordWrap(string value) { + if (DisableWordWrap) { + _displayNewLineIndices = Array.Empty(); + return value; + } + + string displayText = DrawUtil.WrapText(_font, value, this._textRegion.Width, WrapCharacters, out int[] newLineIndices); + _displayNewLineIndices = newLineIndices; + + return displayText; + } + public override int GetCursorIndexFromPosition(int x, int y) { x -= TEXT_LEFTPADDING; y -= TEXT_TOPPADDING; - string[] lines = _text.Split(NEWLINE); + string[] lines = _displayText.Split(NEWLINE); int predictedLine = y / _font.LineHeight; if (predictedLine > lines.Length - 1) { - return _text.Length; + return GetCursorIndexFromDisplayIndex(_displayText.Length); } var glyphs = _font.GetGlyphs(lines[predictedLine]); @@ -85,21 +173,28 @@ public override int GetCursorIndexFromPosition(int x, int y) { charIndex += lines[i].Length + 1; } - return charIndex; + return GetCursorIndexFromDisplayIndex(charIndex); } private Rectangle _textRegion = Rectangle.Empty; private Rectangle[] _highlightRegions = Array.Empty(); private Rectangle _cursorRegion = Rectangle.Empty; + /// + /// The refers to the cursorIndex (in reference to + /// ), while the return value is based on + /// the . + /// private (int Line, int Character) GetSplitIndex(int index) { int lineIndex = 0; int charIndex = 0; + index = GetDisplayIndexFromCursorIndex(index); + for (int i = 0; i < index; i++) { charIndex++; - if (_text[i] == NEWLINE) { + if (_displayText[i] == NEWLINE) { lineIndex++; charIndex = 0; } @@ -114,7 +209,7 @@ private Rectangle[] CalculateHighlightRegions() { if (selectionLength <= 0 || selectionStart + selectionLength > _text.Length) return Array.Empty(); - string[] lines = _text.Split(NEWLINE); + string[] lines = _displayText.Split(NEWLINE); var startIndex = GetSplitIndex(selectionStart); var endIndex = GetSplitIndex(selectionStart + selectionLength); @@ -173,7 +268,7 @@ private Rectangle CalculateTextRegion() { private Rectangle CalculateCursorRegion() { var cursor = GetSplitIndex(_cursorIndex); - string[] lines = _text.Split(NEWLINE); + string[] lines = _displayText.Split(NEWLINE); float cursorLeft = MeasureStringWidth(lines[cursor.Line].Substring(0, cursor.Character)); @@ -191,11 +286,17 @@ private Rectangle CalculateCursorRegion() { } public override void RecalculateLayout() { + _displayText = ProcessDisplayText(_text); _textRegion = CalculateTextRegion(); _highlightRegions = CalculateHighlightRegions(); _cursorRegion = CalculateCursorRegion(); } + protected override void HandleDelete() { + base.HandleDelete(); + RecalculateLayout(); + } + protected override void UpdateScrolling() { /* NOOP */ } protected override void Paint(SpriteBatch spriteBatch, Rectangle bounds) { diff --git a/Blish HUD/Controls/TextInputBase.cs b/Blish HUD/Controls/TextInputBase.cs index 432727a4e..b9bd2915c 100644 --- a/Blish HUD/Controls/TextInputBase.cs +++ b/Blish HUD/Controls/TextInputBase.cs @@ -90,6 +90,14 @@ public string Text { set => SetText(value, false); } + protected string _displayText = string.Empty; + + /// + /// The displayed text. Inheriting classes may process the + /// via . + /// + public string DisplayText => _displayText; + protected int _maxLength = int.MaxValue; /// @@ -485,11 +493,21 @@ protected bool SetText(string value, bool byUser) { _redoStack.Reset(); } + _displayText = ProcessDisplayText(value); + OnTextChanged(new ValueChangedEventArgs(prevText, value)); return true; } + /// + /// Processes the before it is displayed. Result will be + /// used to set . + /// + protected virtual string ProcessDisplayText(string value) { + return value; + } + public override void UnsetFocus() { this.Focused = false; GameService.Input.Keyboard.FocusedControl = null; @@ -785,7 +803,7 @@ protected void PaintText(SpriteBatch spriteBatch, Rectangle textRegion, Horizont } // Draw the text - spriteBatch.DrawStringOnCtrl(this, _text, _font, textRegion, _foreColor, false, false, 0, horizontalAlignment, VerticalAlignment.Top); + spriteBatch.DrawStringOnCtrl(this, _displayText, _font, textRegion, _foreColor, false, false, 0, horizontalAlignment, VerticalAlignment.Top); } protected void PaintHighlight(SpriteBatch spriteBatch, Rectangle highlightRegion) { diff --git a/Blish HUD/_Utils/DrawUtil.cs b/Blish HUD/_Utils/DrawUtil.cs index 1233b5823..68b5d0306 100644 --- a/Blish HUD/_Utils/DrawUtil.cs +++ b/Blish HUD/_Utils/DrawUtil.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using Blish_HUD.Controls; +using System.Collections.Generic; namespace Blish_HUD { public static class DrawUtil { @@ -42,33 +43,213 @@ public static void DrawAlignedText(SpriteBatch sb, BitmapFont sf, string text, R sb.DrawString(sf, text, new Vector2(xPos, yPos), clr); } - /// Source: https://stackoverflow.com/a/15987581/595437 + /// + /// Wraps a , if it does not fit into the + /// accounting for the given . + /// + /// + /// Will prioritize wrapping a word at any of the given , + /// but will wrap in the middle of the word if none of them occur. + /// + /// + /// + /// + /// + /// + /// + /// The with new line characters at appropriate + /// positions to make it fit into the . + private static string WrapWord(BitmapFont spriteFont, string word, float offset, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) { + newLineIndices = Array.Empty(); + if (string.IsNullOrEmpty(word)) return string.Empty; + + if (offset + spriteFont.MeasureString(word).Width <= maxLineWidth) return word; + + StringBuilder resultBuilder = new StringBuilder(); + + List indices = new List(); + bool didSplitCharacterOccur = false; + + StringBuilder partBuilder = new StringBuilder(); + + // this is neccessary, because measuring each character individually and + // adding them up, results in a significant higher value that measuring the whole line + float currentLineWithNewCharacterWidth; + string currentLineCharacters = string.Empty; + + for (int i = 0; i < word.Length; i++) { + if (indices.Any()) { + offset = 0; + } + + char character = word[i]; + currentLineCharacters += character; + currentLineWithNewCharacterWidth = spriteFont.MeasureString(currentLineCharacters).Width + offset; + + if (currentLineWithNewCharacterWidth < maxLineWidth) { + partBuilder.Append(character); + + if (preferredWrapCharacters.Contains(character)) { + resultBuilder.Append(partBuilder); + partBuilder.Clear(); + didSplitCharacterOccur = true; + } + } else { + + int characterOffset = 0; + + if (!didSplitCharacterOccur) { + resultBuilder.Append(partBuilder); + resultBuilder.Append('\n'); + currentLineCharacters = string.Empty; + } + else { + resultBuilder.Append('\n'); + resultBuilder.Append(partBuilder); + currentLineCharacters = partBuilder.ToString(); + + characterOffset = partBuilder.Length; + } + + indices.Add(i + indices.Count() - characterOffset); + + partBuilder.Clear(); + partBuilder.Append(character); + currentLineCharacters += character; + + didSplitCharacterOccur = false; + } + } + + if (partBuilder.Length != 0) { + resultBuilder.Append(partBuilder); + } + + newLineIndices = indices.ToArray(); + return resultBuilder.ToString(); + } + private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth) { - string[] words = text.Split(' '); - var sb = new StringBuilder(); - float lineWidth = 0f; - float spaceWidth = spriteFont.MeasureString(" ").Width; + return WrapTextSegment(spriteFont, text, maxLineWidth, out _); + } - foreach (string word in words) { - Vector2 size = spriteFont.MeasureString(word); + private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth, out int[] newLineIndices) { + return WrapTextSegment(spriteFont, text, maxLineWidth, Array.Empty(), out newLineIndices); + } - if (lineWidth + size.X < maxLineWidth) { - sb.Append(word + " "); - lineWidth += size.X + spaceWidth; + /// + /// Original source: https://stackoverflow.com/a/15987581/595437 + /// (modified) + /// + private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) { + newLineIndices = Array.Empty(); + if (string.IsNullOrEmpty(text)) return string.Empty; + + string[] words = text.Split(' '); + var sb = new StringBuilder(); + float currentLineWidth = 0f; + float spaceWidth = spriteFont.MeasureString(" ").Width; + + List indices = new List(); + int processedCharacters = 0; + + for (int i = 0; i < words.Length; i++) { + string word = words[i]; + float wordWidth = spriteFont.MeasureString(word).Width; + + if (currentLineWidth + wordWidth < maxLineWidth) { + sb.Append(word); + currentLineWidth += wordWidth; + if (i < words.Length - 1) { + sb.Append(" "); + currentLineWidth += spaceWidth; + processedCharacters++; + } } else { - sb.Append("\n" + word + " "); - lineWidth = size.X + spaceWidth; + string wrappedWord = WrapWord(spriteFont, word, currentLineWidth, maxLineWidth, preferredWrapCharacters, out int[] wordNewLineIndices); + + string firstPart = wrappedWord; + string lastPart = wrappedWord; + + if (wordNewLineIndices.Length != 0) { + firstPart = wrappedWord.Substring(0, wordNewLineIndices.First()); + lastPart = wrappedWord.Substring(wordNewLineIndices.Last() + 1); + } + + // words should only every be broken in the middle of the word (no wrap character + // in the first part), if they started on their own line. + if (preferredWrapCharacters.Any(character => firstPart.Contains(character)) || currentLineWidth == 0) { + sb.Append(wrappedWord); + currentLineWidth = spriteFont.MeasureString(lastPart).Width; + + int indexOffset = processedCharacters + indices.Count(); + + foreach (int wordIndex in wordNewLineIndices) { + indices.Add(wordIndex + indexOffset); + } + } else { + string wrappedWordOnNextLine = WrapWord(spriteFont, word, 0, maxLineWidth, preferredWrapCharacters, out int[] wordOnNextLineNewLineIndices); + sb.Append('\n'); + indices.Add(processedCharacters + indices.Count()); + sb.Append(wrappedWordOnNextLine); + currentLineWidth = spriteFont.MeasureString(wrappedWordOnNextLine.Split('\n').Last()).Width; + + int indexOffset = processedCharacters + indices.Count(); + + foreach (int wordIndex in wordOnNextLineNewLineIndices) { + indices.Add(wordIndex + indexOffset); + } + } + + if (i < words.Length - 1) { + sb.Append(" "); + processedCharacters++; + currentLineWidth += spaceWidth; + } } + processedCharacters += word.Length; } + newLineIndices = indices.ToArray(); return sb.ToString(); } public static string WrapText(BitmapFont spriteFont, string text, float maxLineWidth) { - if (string.IsNullOrEmpty(text)) return ""; + return WrapText(spriteFont, text, maxLineWidth, out _); + } - return string.Join("\n", text.Split('\n').Select(s => WrapTextSegment(spriteFont, s, maxLineWidth))); + public static string WrapText(BitmapFont spriteFont, string text, float maxLineWidth, out int[] newLineIndices) { + return WrapText(spriteFont, text, maxLineWidth, Array.Empty(), out newLineIndices); } + public static string WrapText(BitmapFont spriteFont, string text, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) { + newLineIndices = Array.Empty(); + if (string.IsNullOrEmpty(text)) return ""; + + var sb = new StringBuilder(); + List indices = new List(); + int processedCharacters = 0; + + string[] lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) { + sb.Append(WrapTextSegment(spriteFont, lines[i], maxLineWidth, preferredWrapCharacters, out int[] segmentNewLineIndices)); + + int indexOffset = processedCharacters + indices.Count(); + + foreach (int segmentIndex in segmentNewLineIndices) { + indices.Add(segmentIndex + indexOffset); + } + + processedCharacters += lines[i].Length; + + if (i < lines.Length - 1) { + sb.Append('\n'); + processedCharacters++; + } + } + + newLineIndices = indices.ToArray(); + return sb.ToString(); + } } }