Allow formatting codes to have an arbitrary custom width

This commit is contained in:
Ell 2022-12-06 16:49:19 +01:00
parent cf3d0e8e0c
commit b374d50815
9 changed files with 94 additions and 25 deletions

View File

@ -32,6 +32,7 @@ Improvements
- Improved the way InputHandler down time calculation works
- Allow explicitly specifying each region for extended auto tiles
- Added a generic version of IGenericDataHolder.SetData
- Allow formatting codes to have an arbitrary custom width
- **Drastically improved StaticSpriteBatch batching performance**
- **Made GenericFont and TokenizedString support UTF-32 characters like emoji**
@ -44,6 +45,7 @@ Fixes
Removals
- Removed DataContract attribute from GenericDataHolder
- Marked EnumHelper as obsolete due to its reimplementation in [DynamicEnums](https://www.nuget.org/packages/DynamicEnums)
- Marked Code.GetReplacementString as obsolete
### MLEM.Ui
Additions

View File

@ -1,6 +1,8 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MLEM.Extensions;
using MLEM.Font;
using MLEM.Formatting;
using MLEM.Formatting.Codes;
@ -23,6 +25,7 @@ namespace Demos {
private TextFormatter formatter;
private TokenizedString tokenizedText;
private GenericFont font;
private bool drawBounds;
public TextFormattingDemo(MlemGame game) : base(game) {}
@ -56,6 +59,16 @@ namespace Demos {
// since the text is already center-aligned, we only need to align it on the y axis here
var size = this.tokenizedText.Measure(this.font) * TextFormattingDemo.Scale;
var pos = new Vector2(this.GraphicsDevice.Viewport.Width / 2, (this.GraphicsDevice.Viewport.Height - size.Y) / 2);
// draw bounds, which can be toggled with B in this demo
if (this.drawBounds) {
foreach (var token in this.tokenizedText.Tokens) {
foreach (var area in token.GetArea(pos, TextFormattingDemo.Scale))
this.SpriteBatch.Draw(this.SpriteBatch.GetBlankTexture(), area, Color.Black * 0.25F);
}
}
// draw the text itself
this.tokenizedText.Draw(time, this.SpriteBatch, pos, this.font, Color.White, TextFormattingDemo.Scale, 0);
this.SpriteBatch.End();
@ -64,6 +77,8 @@ namespace Demos {
public override void Update(GameTime time) {
// update our tokenized string to animate the animation codes
this.tokenizedText.Update(time);
if (this.InputHandler.IsPressed(Keys.B))
this.drawBounds = !this.drawBounds;
}
public override void Clear() {

View File

@ -110,12 +110,12 @@ namespace MLEM.Font {
/// <param name="ignoreTrailingSpaces">Whether trailing whitespace should be ignored in the returned size, causing the end of each line to be effectively trimmed</param>
/// <returns>The size of the string when drawn with this font</returns>
public Vector2 MeasureString(string text, bool ignoreTrailingSpaces = false) {
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces, null);
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces, null, null);
}
/// <inheritdoc cref="MeasureString(string,bool)"/>
public Vector2 MeasureString(StringBuilder text, bool ignoreTrailingSpaces = false) {
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces, null);
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces, null, null);
}
/// <summary>
@ -129,12 +129,12 @@ namespace MLEM.Font {
/// <param name="ellipsis">The characters to add to the end of the string if it is too long</param>
/// <returns>The truncated string, or the same string if it is shorter than the maximum width</returns>
public string TruncateString(string text, float width, float scale, bool fromBack = false, string ellipsis = "") {
return this.TruncateString(new CodePointSource(text), width, scale, fromBack, ellipsis, null).ToString();
return this.TruncateString(new CodePointSource(text), width, scale, fromBack, ellipsis, null, null).ToString();
}
/// <inheritdoc cref="TruncateString(string,float,float,bool,string)"/>
public StringBuilder TruncateString(StringBuilder text, float width, float scale, bool fromBack = false, string ellipsis = "") {
return this.TruncateString(new CodePointSource(text), width, scale, fromBack, ellipsis, null);
return this.TruncateString(new CodePointSource(text), width, scale, fromBack, ellipsis, null, null);
}
/// <summary>
@ -165,21 +165,22 @@ namespace MLEM.Font {
/// <param name="scale">The scale to use for width measurements</param>
/// <returns>The split string as an enumerable of split sections</returns>
public IEnumerable<string> SplitStringSeparate(string text, float width, float scale) {
return this.SplitStringSeparate(new CodePointSource(text), width, scale, null);
return this.SplitStringSeparate(new CodePointSource(text), width, scale, null, null);
}
/// <inheritdoc cref="SplitStringSeparate(string,float,float)"/>
public IEnumerable<string> SplitStringSeparate(StringBuilder text, float width, float scale) {
return this.SplitStringSeparate(new CodePointSource(text), width, scale, null);
return this.SplitStringSeparate(new CodePointSource(text), width, scale, null, null);
}
internal Vector2 MeasureString(CodePointSource text, bool ignoreTrailingSpaces, Func<int, GenericFont> fontFunction) {
internal Vector2 MeasureString(CodePointSource text, bool ignoreTrailingSpaces, Func<int, GenericFont> fontFunction, Func<int, float> extraWidthFunction) {
var size = Vector2.Zero;
if (text.Length <= 0)
return size;
var xOffset = 0F;
var index = 0;
while (index < text.Length) {
xOffset += extraWidthFunction?.Invoke(index) ?? 0;
var font = fontFunction?.Invoke(index) ?? this;
var (codePoint, length) = text.GetCodePoint(index);
switch (codePoint) {
@ -218,7 +219,7 @@ namespace MLEM.Font {
return size;
}
internal StringBuilder TruncateString(CodePointSource text, float width, float scale, bool fromBack, string ellipsis, Func<int, GenericFont> fontFunction) {
internal StringBuilder TruncateString(CodePointSource text, float width, float scale, bool fromBack, string ellipsis, Func<int, GenericFont> fontFunction, Func<int, float> extraWidthFunction) {
var total = new StringBuilder();
var index = 0;
while (index < text.Length) {
@ -230,7 +231,7 @@ namespace MLEM.Font {
total.Append(CodePointSource.ToString(codePoint));
}
if (this.MeasureString(new CodePointSource(total + ellipsis), false, fontFunction).X * scale >= width) {
if (this.MeasureString(new CodePointSource(total + ellipsis), false, fontFunction, extraWidthFunction).X * scale >= width) {
if (fromBack) {
return total.Remove(0, length).Insert(0, ellipsis);
} else {
@ -242,7 +243,7 @@ namespace MLEM.Font {
return total;
}
internal IEnumerable<string> SplitStringSeparate(CodePointSource text, float width, float scale, Func<int, GenericFont> fontFunction) {
internal IEnumerable<string> SplitStringSeparate(CodePointSource text, float width, float scale, Func<int, GenericFont> fontFunction, Func<int, float> extraWidthFunction) {
var currWidth = 0F;
var lastSpaceIndex = -1;
var widthSinceLastSpace = 0F;
@ -259,7 +260,7 @@ namespace MLEM.Font {
} else {
var font = fontFunction?.Invoke(index) ?? this;
var character = CodePointSource.ToString(codePoint);
var charWidth = font.MeasureString(character).X * scale;
var charWidth = (font.MeasureString(character).X + (extraWidthFunction?.Invoke(index) ?? 0)) * scale;
if (codePoint == ' ' || codePoint == GenericFont.Emsp || codePoint == GenericFont.Zwsp) {
// remember the location of this (breaking!) space
lastSpaceIndex = curr.Length;
@ -298,7 +299,7 @@ namespace MLEM.Font {
var flippedV = (effects & SpriteEffects.FlipVertically) != 0;
var flippedH = (effects & SpriteEffects.FlipHorizontally) != 0;
if (flippedV || flippedH) {
var size = this.MeasureString(text, false, null);
var size = this.MeasureString(text, false, null, null);
if (flippedH) {
origin.X *= -1;
flipX = -size.X;

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Xna.Framework;
@ -68,6 +69,11 @@ namespace MLEM.Formatting.Codes {
return null;
}
/// <inheritdoc cref="Token.GetSelfWidth"/>
public virtual float GetSelfWidth(GenericFont font) {
return 0;
}
/// <summary>
/// Update this formatting code's animations etc.
/// </summary>
@ -80,6 +86,7 @@ namespace MLEM.Formatting.Codes {
/// </summary>
/// <param name="font">The font that is used</param>
/// <returns>The replacement string for this formatting code</returns>
[Obsolete("This method is deprecated. Use GetSelfWidth to add additional width to this code and DrawSelf or DrawCharacter to draw additional items.")]
public virtual string GetReplacementString(GenericFont font) {
return string.Empty;
}

View File

@ -26,8 +26,8 @@ namespace MLEM.Formatting.Codes {
}
/// <inheritdoc />
public override string GetReplacementString(GenericFont font) {
return GenericFont.Emsp.ToString();
public override float GetSelfWidth(GenericFont font) {
return font.LineHeight;
}
/// <inheritdoc />

View File

@ -142,8 +142,11 @@ namespace MLEM.Formatting {
}
private static string StripFormatting(GenericFont font, string s, IEnumerable<Code> codes) {
foreach (var code in codes)
foreach (var code in codes) {
#pragma warning disable CS0618
s = code.Regex.Replace(s, code.GetReplacementString(font));
#pragma warning restore CS0618
}
return s;
}

View File

@ -80,6 +80,19 @@ namespace MLEM.Formatting {
return defaultPick;
}
/// <summary>
/// Returns the width of the token itself, including all of the <see cref="Code"/> instances that this token contains.
/// Note that this method does not return the width of this token's <see cref="DisplayString"/>, but only the width that the codes themselves take up.
/// </summary>
/// <param name="font">The font to use for calculating the width.</param>
/// <returns>The width of this token itself.</returns>
public float GetSelfWidth(GenericFont font) {
var ret = 0F;
foreach (var code in this.AppliedCodes)
ret += code.GetSelfWidth(font);
return ret;
}
/// <summary>
/// Draws the token itself, including all of the <see cref="Code"/> instances that this token contains.
/// Note that, to draw the token's actual string, <see cref="DrawCharacter"/> is used.

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@ -63,7 +64,8 @@ namespace MLEM.Formatting {
/// <param name="alignment">The text alignment that should be used for width calculations</param>
public void Split(GenericFont font, float width, float scale, TextAlignment alignment = TextAlignment.Left) {
// a split string has the same character count as the input string but with newline characters added
this.modifiedString = string.Join("\n", font.SplitStringSeparate(new CodePointSource(this.String), width, scale, i => this.GetFontForIndex(font, i)));
this.modifiedString = string.Join("\n", font.SplitStringSeparate(new CodePointSource(this.String), width, scale,
i => this.GetFontForIndex(font, i), i => this.GetSelfWidthForIndex(font, i)));
this.StoreModifiedSubstrings(font, alignment);
}
@ -78,7 +80,8 @@ namespace MLEM.Formatting {
/// <param name="ellipsis">The characters to add to the end of the string if it is too long</param>
/// <param name="alignment">The text alignment that should be used for width calculations</param>
public void Truncate(GenericFont font, float width, float scale, string ellipsis = "", TextAlignment alignment = TextAlignment.Left) {
this.modifiedString = font.TruncateString(new CodePointSource(this.String), width, scale, false, ellipsis, i => this.GetFontForIndex(font, i)).ToString();
this.modifiedString = font.TruncateString(new CodePointSource(this.String), width, scale, false, ellipsis,
i => this.GetFontForIndex(font, i), i => this.GetSelfWidthForIndex(font, i)).ToString();
this.StoreModifiedSubstrings(font, alignment);
}
@ -102,7 +105,10 @@ namespace MLEM.Formatting {
token.InnerOffsets = new float[token.SplitDisplayString.Length - 1];
var area = new List<RectangleF>();
for (var l = 0; l < token.SplitDisplayString.Length; l++) {
var size = tokenFont.MeasureString(token.SplitDisplayString[l]);
var size = tokenFont.MeasureString(token.SplitDisplayString[l], !this.EndsLater(t, l));
if (l == 0)
size.X += token.GetSelfWidth(tokenFont);
var rect = new RectangleF(innerOffset, size);
if (!rect.IsEmpty)
area.Add(rect);
@ -120,7 +126,7 @@ namespace MLEM.Formatting {
/// <inheritdoc cref="GenericFont.MeasureString(string,bool)"/>
public Vector2 Measure(GenericFont font) {
return font.MeasureString(new CodePointSource(this.DisplayString), false, i => this.GetFontForIndex(font, i));
return font.MeasureString(new CodePointSource(this.DisplayString), false, i => this.GetFontForIndex(font, i), i => this.GetSelfWidthForIndex(font, i));
}
/// <summary>
@ -156,6 +162,7 @@ namespace MLEM.Formatting {
for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var drawFont = token.GetFont(font);
var selfWidth = token.GetSelfWidth(drawFont);
var drawColor = token.GetColor(color);
var indexInToken = 0;
@ -171,6 +178,8 @@ namespace MLEM.Formatting {
token.DrawCharacter(time, batch, codePoint, character, indexInToken, pos + innerOffset, drawFont, drawColor, scale, depth);
innerOffset.X += drawFont.MeasureString(character).X * scale;
if (indexInToken == 0)
innerOffset.X += selfWidth * scale;
charIndex += length;
indexInToken++;
}
@ -224,14 +233,15 @@ namespace MLEM.Formatting {
if (alignment > TextAlignment.Left) {
var token = this.Tokens[tokenIndex];
var tokenFont = token.GetFont(defaultFont);
// if we're the last line in our line array, then we don't contain a line split, so the line ends in a later token
var endsLater = lineIndex >= token.SplitDisplayString.Length - 1 && tokenIndex < this.Tokens.Length - 1;
var tokenWidth = lineIndex <= 0 ? token.GetSelfWidth(tokenFont) : 0;
var endsLater = this.EndsLater(tokenIndex, lineIndex);
// if the line ends in our token, we should ignore trailing white space
var restOfLine = tokenFont.MeasureString(token.SplitDisplayString[lineIndex], !endsLater).X;
var restOfLine = tokenFont.MeasureString(token.SplitDisplayString[lineIndex], !endsLater).X + tokenWidth;
if (endsLater) {
for (var i = tokenIndex + 1; i < this.Tokens.Length; i++) {
var other = this.Tokens[i];
restOfLine += other.GetFont(defaultFont).MeasureString(other.SplitDisplayString[0], true).X;
var otherWidth = other.GetSelfWidth(defaultFont);
restOfLine += other.GetFont(defaultFont).MeasureString(other.SplitDisplayString[0], !this.EndsLater(i, 0)).X + otherWidth;
// if the token's split display string has multiple lines, then the line ends in it, which means we can stop
if (other.SplitDisplayString.Length > 1)
break;
@ -253,5 +263,21 @@ namespace MLEM.Formatting {
return null;
}
private float GetSelfWidthForIndex(GenericFont font, int index) {
foreach (var token in this.Tokens) {
if (index == token.Index)
return token.GetSelfWidth(font);
// if we're already beyond this token, any later tokens definitely won't match
if (token.Index > index)
break;
}
return 0;
}
private bool EndsLater(int tokenIndex, int lineIndex) {
// if we're the last line in our line array, then we don't contain a line split, so the line ends in a later token
return lineIndex >= this.Tokens[tokenIndex].SplitDisplayString.Length - 1 && tokenIndex < this.Tokens.Length - 1;
}
}
}

View File

@ -8,7 +8,7 @@ using MLEM.Formatting.Codes;
using MLEM.Textures;
using NUnit.Framework;
namespace Tests;
namespace Tests;
public class FontTests {
@ -121,7 +121,7 @@ public class FontTests {
const string strg = "Lorem Ipsum <i Test> is simply dummy text of the <i Test> printing and typesetting <i Test> industry. Lorem Ipsum has been the industry's standard dummy text <i Test> ever since the <i Test> 1500s, when <i Test><i Test><i Test><i Test><i Test><i Test><i Test> an unknown printer took a galley of type and scrambled it to make a type specimen book.";
var ret = this.formatter.Tokenize(this.font, strg);
Assert.AreEqual(ret.Tokens.Length, 13);
Assert.AreEqual(ret.DisplayString, "Lorem Ipsum \u2003 is simply dummy text of the \u2003 printing and typesetting \u2003 industry. Lorem Ipsum has been the industry's standard dummy text \u2003 ever since the \u2003 1500s, when \u2003\u2003\u2003\u2003\u2003\u2003\u2003 an unknown printer took a galley of type and scrambled it to make a type specimen book.");
Assert.AreEqual(ret.DisplayString, "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.");
Assert.AreEqual(ret.AllCodes.Length, 12);
}
@ -137,6 +137,8 @@ public class FontTests {
CompareSizes("\nThis is a very simple test string");
CompareSizes("This is a very simple test string\n");
CompareSizes("This is a very simple test string\n\n\n\n\n");
CompareSizes("This is a very simple test string");
CompareSizes("This is a very simple \n test string");
}
[Test]