1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-04-28 23:29:06 +02:00
MLEM/MLEM/Formatting/TokenizedString.cs

284 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Font;
using MLEM.Formatting.Codes;
using MLEM.Misc;
namespace MLEM.Formatting {
/// <summary>
/// A tokenized string that was created using a <see cref="TextFormatter"/>
/// </summary>
public class TokenizedString : GenericDataHolder {
/// <summary>
/// The raw string that was used to create this tokenized string.
/// </summary>
public readonly string RawString;
/// <summary>
/// The <see cref="RawString"/>, but with formatting codes stripped out.
/// </summary>
public readonly string String;
/// <summary>
/// The string that is actually displayed by this tokenized string.
/// If this string has been <see cref="Split"/> or <see cref="Truncate"/> has been used, this string will contain the newline characters.
/// </summary>
public string DisplayString => this.modifiedString ?? this.String;
/// <summary>
/// The tokens that this tokenized string contains.
/// </summary>
public readonly Token[] Tokens;
/// <summary>
/// All of the formatting codes that are applied over this tokenized string.
/// Note that, to get a formatting code for a certain token, use <see cref="Token.AppliedCodes"/>
/// </summary>
public readonly Code[] AllCodes;
private string modifiedString;
private float initialInnerOffset;
internal TokenizedString(GenericFont font, TextAlignment alignment, string rawString, string strg, Token[] tokens) {
this.RawString = rawString;
this.String = strg;
this.Tokens = tokens;
// since a code can be present in multiple tokens, we use Distinct here
this.AllCodes = tokens.SelectMany(t => t.AppliedCodes).Distinct().ToArray();
// TODO this can probably be optimized by keeping track of a code's tokens while tokenizing
foreach (var code in this.AllCodes)
code.Tokens = new ReadOnlyCollection<Token>(this.Tokens.Where(t => t.AppliedCodes.Contains(code)).ToList());
this.Realign(font, alignment);
}
/// <summary>
/// Splits this tokenized string, inserting newline characters if the width of the string is bigger than the maximum width.
/// Note that a tokenized string can be re-split without losing any of its actual data, as this operation merely modifies the <see cref="DisplayString"/>.
/// </summary>
/// <param name="font">The font to use for width calculations</param>
/// <param name="width">The maximum width, in display pixels based on the font and scale</param>
/// <param name="scale">The scale to use for width measurements</param>
/// <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), i => this.GetSelfWidthForIndex(font, i)));
this.StoreModifiedSubstrings(font, alignment);
}
/// <summary>
/// Truncates this tokenized string, removing any additional characters that exceed the length from the displayed string.
/// Note that a tokenized string can be re-truncated without losing any of its actual data, as this operation merely modifies the <see cref="DisplayString"/>.
/// <seealso cref="GenericFont.TruncateString(string,float,float,bool,string)"/>
/// </summary>
/// <param name="font">The font to use for width calculations</param>
/// <param name="width">The maximum width, in display pixels based on the font and scale</param>
/// <param name="scale">The scale to use for width measurements</param>
/// <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), i => this.GetSelfWidthForIndex(font, i)).ToString();
this.StoreModifiedSubstrings(font, alignment);
}
/// <summary>
/// Realigns this tokenized string using the given <see cref="TextAlignment"/>.
/// If the <paramref name="alignment"/> is <see cref="TextAlignment.Right"/>, trailing space characters (but not <see cref="GenericFont.Nbsp"/>) will be removed.
/// </summary>
/// <param name="font">The font to use for width calculations.</param>
/// <param name="alignment">The text alignment that should be used for width calculations.</param>
public void Realign(GenericFont font, TextAlignment alignment) {
// split display strings
foreach (var token in this.Tokens)
token.SplitDisplayString = token.DisplayString.Split('\n');
// token areas and inner offsets
this.initialInnerOffset = this.GetInnerOffsetX(font, 0, 0, alignment);
var innerOffset = new Vector2(this.initialInnerOffset, 0);
for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var tokenFont = token.GetFont(font);
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], !this.EndsLater(t, l));
if (l == 0)
size.X += token.GetSelfWidth(tokenFont);
var rect = new RectangleF(innerOffset, size);
if (!rect.IsEmpty)
area.Add(rect);
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = token.InnerOffsets[l] = this.GetInnerOffsetX(font, t, l + 1, alignment);
innerOffset.Y += font.LineHeight;
} else {
innerOffset.X += size.X;
}
}
token.Area = area.ToArray();
}
}
/// <inheritdoc cref="GenericFont.MeasureString(string,bool)"/>
public Vector2 Measure(GenericFont font) {
return font.MeasureString(new CodePointSource(this.DisplayString), false, i => this.GetFontForIndex(font, i), i => this.GetSelfWidthForIndex(font, i));
}
/// <summary>
/// Updates the formatting codes in this formatted string, causing animations to animate etc.
/// </summary>
/// <param name="time">The game's time</param>
public void Update(GameTime time) {
foreach (var code in this.AllCodes)
code.Update(time);
}
/// <summary>
/// Returns the token under the given position.
/// This can be used for hovering effects when the mouse is over a token, etc.
/// </summary>
/// <param name="stringPos">The position that the string is drawn at</param>
/// <param name="target">The position to use for checking the token</param>
/// <param name="scale">The scale that the string is drawn at</param>
/// <returns>The token under the target position</returns>
public Token GetTokenUnderPos(Vector2 stringPos, Vector2 target, float scale) {
foreach (var token in this.Tokens) {
foreach (var rect in token.GetArea(stringPos, scale)) {
if (rect.Contains(target))
return token;
}
}
return null;
}
/// <inheritdoc cref="GenericFont.DrawString(SpriteBatch,string,Vector2,Color,float,Vector2,float,SpriteEffects,float)"/>
public void Draw(GameTime time, SpriteBatch batch, Vector2 pos, GenericFont font, Color color, float scale, float depth) {
var innerOffset = new Vector2(this.initialInnerOffset * scale, 0);
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;
for (var l = 0; l < token.SplitDisplayString.Length; l++) {
var charIndex = 0;
var line = new CodePointSource(token.SplitDisplayString[l]);
while (charIndex < line.Length) {
var (codePoint, length) = line.GetCodePoint(charIndex);
var character = CodePointSource.ToString(codePoint);
if (indexInToken == 0)
token.DrawSelf(time, batch, pos + innerOffset, drawFont, color, scale, depth);
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++;
}
// only split at a new line, not between tokens!
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = token.InnerOffsets[l] * scale;
innerOffset.Y += font.LineHeight * scale;
}
}
}
}
private void StoreModifiedSubstrings(GenericFont font, TextAlignment alignment) {
if (this.Tokens.Length == 1) {
// skip substring logic for unformatted text
this.Tokens[0].ModifiedSubstring = this.modifiedString;
} else {
// this is basically a substring function that ignores added newlines for indexing
var index = 0;
var currToken = 0;
var splitIndex = 0;
var ret = new StringBuilder();
while (splitIndex < this.modifiedString.Length && currToken < this.Tokens.Length) {
var token = this.Tokens[currToken];
if (token.Substring.Length > 0) {
ret.Append(this.modifiedString[splitIndex]);
// if the current char is not an added newline, we simulate length increase
if (this.modifiedString[splitIndex] != '\n' || this.String[index] == '\n')
index++;
splitIndex++;
}
// move on to the next token if we reached its end
if (index >= token.Index + token.Substring.Length) {
token.ModifiedSubstring = ret.ToString();
ret.Clear();
currToken++;
}
}
// set additional token contents beyond our string in case we truncated
if (ret.Length > 0)
this.Tokens[currToken++].ModifiedSubstring = ret.ToString();
while (currToken < this.Tokens.Length)
this.Tokens[currToken++].ModifiedSubstring = string.Empty;
}
this.Realign(font, alignment);
}
private float GetInnerOffsetX(GenericFont defaultFont, int tokenIndex, int lineIndex, TextAlignment alignment) {
if (alignment > TextAlignment.Left) {
var token = this.Tokens[tokenIndex];
var tokenFont = token.GetFont(defaultFont);
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 + tokenWidth;
if (endsLater) {
for (var i = tokenIndex + 1; i < this.Tokens.Length; i++) {
var other = this.Tokens[i];
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;
}
}
if (alignment == TextAlignment.Center)
restOfLine /= 2;
return -restOfLine;
}
return 0;
}
private GenericFont GetFontForIndex(GenericFont font, int index) {
foreach (var token in this.Tokens) {
index -= token.Substring.Length;
if (index <= 0)
return token.GetFont(font);
}
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;
}
}
}