Skip to content

Commit

Permalink
Merge pull request #1 from Flyga-M/feature/multiline-text-box-improve…
Browse files Browse the repository at this point in the history
…ments

finishing touches on word-wrap for MultilineTextBoxes
  • Loading branch information
Flyga-M authored Oct 5, 2024
2 parents af141f9 + 79d4b26 commit 9c6e673
Show file tree
Hide file tree
Showing 2 changed files with 178 additions and 23 deletions.
28 changes: 27 additions & 1 deletion Blish HUD/Controls/MultilineTextBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ public bool HideBackground {
/// </summary>
public int[] DisplayNewLineIndices => _displayNewLineIndices;

private bool _disableWordWrap;

/// <summary>
/// Determines whether the automatic word-wrap will be disabled.
/// </summary>
public bool DisableWordWrap {
get => _disableWordWrap;
set => SetProperty(ref _disableWordWrap, value);
}

private char[] _wrapCharacters;

/// <summary>
/// The characters, that are used to wrap a word, if it does not fit the current line
/// it's on.
/// </summary>
public char[] WrapCharacters {
get => _wrapCharacters ?? Array.Empty<char>();
set => SetProperty(ref _wrapCharacters, value);
}

public MultilineTextBox() {
_multiline = true;
_maxLength = 524288;
Expand Down Expand Up @@ -105,7 +126,12 @@ protected override string ProcessDisplayText(string value) {
/// Applies word-wrap to the <paramref name="value"/>.
/// </summary>
protected string ApplyWordWrap(string value) {
string displayText = DrawUtil.WrapText(_font, value, this._textRegion.Width, out int[] newLineIndices);
if (DisableWordWrap) {
_displayNewLineIndices = Array.Empty<int>();
return value;
}

string displayText = DrawUtil.WrapText(_font, value, this._textRegion.Width, WrapCharacters, out int[] newLineIndices);
_displayNewLineIndices = newLineIndices;

return displayText;
Expand Down
173 changes: 151 additions & 22 deletions Blish HUD/_Utils/DrawUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,48 +43,171 @@ public static void DrawAlignedText(SpriteBatch sb, BitmapFont sf, string text, R
sb.DrawString(sf, text, new Vector2(xPos, yPos), clr);
}

/// <summary>
/// Wraps a <paramref name="word"/>, if it does not fit into the <paramref name="maxLineWidth"/>
/// accounting for the given <paramref name="offset"/>.
/// </summary>
/// <remarks>
/// Will prioritize wrapping a word at any of the given <paramref name="preferredWrapCharacters"/>,
/// but will wrap in the middle of the word if none of them occur.
/// </remarks>
/// <param name="spriteFont"></param>
/// <param name="word"></param>
/// <param name="offset"></param>
/// <param name="maxLineWidth"></param>
/// <param name="preferredWrapCharacters"></param>
/// <param name="newLineIndices"></param>
/// <returns>The <paramref name="word"/> with new line characters at appropriate
/// positions to make it fit into the <paramref name="maxLineWidth"/>.</returns>
private static string WrapWord(BitmapFont spriteFont, string word, float offset, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) {
newLineIndices = Array.Empty<int>();
if (string.IsNullOrEmpty(word)) return string.Empty;

if (offset + spriteFont.MeasureString(word).Width <= maxLineWidth) return word;

StringBuilder resultBuilder = new StringBuilder();

List<int> indices = new List<int>();
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) {
return WrapTextSegment(spriteFont, text, maxLineWidth, out _);
}

private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth, out int[] newLineIndices) {
return WrapTextSegment(spriteFont, text, maxLineWidth, Array.Empty<char>(), out newLineIndices);
}

/// <remarks>
/// Source: https://stackoverflow.com/a/15987581/595437
/// (slightly modified)
/// Original source: https://stackoverflow.com/a/15987581/595437
/// (modified)
/// </remarks>
private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth, out int[] newLineIndices) {
private static string WrapTextSegment(BitmapFont spriteFont, string text, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) {
newLineIndices = Array.Empty<int>();
if (string.IsNullOrEmpty(text)) return string.Empty;

string[] words = text.Split(' ');
var sb = new StringBuilder();
float lineWidth = 0f;
float currentLineWidth = 0f;
float spaceWidth = spriteFont.MeasureString(" ").Width;

List<int> indices = new List<int>();
int characterIndex = 0;
int processedCharacters = 0;

for (int i = 0; i < words.Length; i++) {
string word = words[i];
Vector2 size = spriteFont.MeasureString(word);
float wordWidth = spriteFont.MeasureString(word).Width;

if (lineWidth + size.X < maxLineWidth) {
if (currentLineWidth + wordWidth < maxLineWidth) {
sb.Append(word);
lineWidth += size.X;
currentLineWidth += wordWidth;
if (i < words.Length - 1) {
sb.Append(" ");
lineWidth += spaceWidth;
currentLineWidth += spaceWidth;
processedCharacters++;
}
} else {
sb.Append("\n" + word);
lineWidth = size.X;
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(" ");
lineWidth += spaceWidth;
processedCharacters++;
currentLineWidth += spaceWidth;
}
indices.Add(characterIndex);
characterIndex++;
}
characterIndex += word.Length + 1;
processedCharacters += word.Length;
}

newLineIndices = indices.ToArray();
Expand All @@ -96,31 +219,37 @@ public static string WrapText(BitmapFont spriteFont, string text, float maxLineW
}

public static string WrapText(BitmapFont spriteFont, string text, float maxLineWidth, out int[] newLineIndices) {
return WrapText(spriteFont, text, maxLineWidth, Array.Empty<char>(), out newLineIndices);
}

public static string WrapText(BitmapFont spriteFont, string text, float maxLineWidth, char[] preferredWrapCharacters, out int[] newLineIndices) {
newLineIndices = Array.Empty<int>();
if (string.IsNullOrEmpty(text)) return "";

var sb = new StringBuilder();
List<int> indices = new List<int>();
int lineStart = 0;
int processedCharacters = 0;

string[] lines = text.Split('\n');
for (int i = 0; i < lines.Length; i++) {
sb.Append(WrapTextSegment(spriteFont, lines[i], maxLineWidth, out int[] localNewLineIndices));
foreach (int localIndex in localNewLineIndices) {
indices.Add(localIndex + lineStart);
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);
}

lineStart += lines[i].Length;
processedCharacters += lines[i].Length;

if (i < lines.Length - 1) {
sb.Append('\n');
lineStart++;
processedCharacters++;
}
}

newLineIndices = indices.ToArray();
return sb.ToString();
}

}
}

0 comments on commit 9c6e673

Please sign in to comment.