1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-11-22 12:58:33 +01:00

added text alignment options to tokenized strings and paragraphs

This commit is contained in:
Ell 2021-06-25 15:23:30 +02:00
parent d1ce9412a2
commit 14940d39c5
7 changed files with 188 additions and 72 deletions

View file

@ -6,6 +6,7 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions; using MLEM.Extensions;
using MLEM.Font; using MLEM.Font;
using MLEM.Formatting;
using MLEM.Formatting.Codes; using MLEM.Formatting.Codes;
using MLEM.Misc; using MLEM.Misc;
using MLEM.Startup; using MLEM.Startup;
@ -191,6 +192,14 @@ namespace Demos {
this.root.AddChild(new VerticalSpace(3)); this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled button", "This button can't be clicked or moved to using automatic navigation") {IsDisabled = true}).PositionOffset = new Vector2(0, 1); this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled button", "This button can't be clicked or moved to using automatic navigation") {IsDisabled = true}).PositionOffset = new Vector2(0, 1);
const string alignText = "Paragraphs can have <c CornflowerBlue><l Left>left</l></c> aligned text, <c CornflowerBlue><l Right>right</l></c> aligned text and <c CornflowerBlue><l Center>center</l></c> aligned text.";
this.root.AddChild(new VerticalSpace(3));
var alignPar = this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, alignText));
alignPar.LinkAction = (l, c) => {
if (Enum.TryParse<TextAlignment>(c.Match.Groups[1].Value, out var alignment))
alignPar.Alignment = alignment;
};
this.root.AddChild(new VerticalSpace(3)); this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "The code for this demo contains some examples for how to query element data. This is the output of that:")); this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "The code for this demo contains some examples for how to query element data. This is the output of that:"));

View file

@ -16,7 +16,6 @@ namespace MLEM.Ui.Elements {
/// </summary> /// </summary>
public class Paragraph : Element { public class Paragraph : Element {
private string text;
/// <summary> /// <summary>
/// The font that this paragraph draws text with. /// The font that this paragraph draws text with.
/// To set its bold and italic font, use <see cref="GenericFont.Bold"/> and <see cref="GenericFont.Italic"/>. /// To set its bold and italic font, use <see cref="GenericFont.Bold"/> and <see cref="GenericFont.Italic"/>.
@ -84,6 +83,20 @@ namespace MLEM.Ui.Elements {
/// By default, <see cref="MlemPlatform.OpenLinkOrFile"/> is executed. /// By default, <see cref="MlemPlatform.OpenLinkOrFile"/> is executed.
/// </summary> /// </summary>
public Action<Link, LinkCode> LinkAction; public Action<Link, LinkCode> LinkAction;
/// <summary>
/// The <see cref="TextAlignment"/> that this paragraph's text should be rendered with
/// </summary>
public TextAlignment Alignment {
get => this.alignment;
set {
this.alignment = value;
this.SetAreaDirty();
this.TokenizedText = null;
}
}
private string text;
private TextAlignment alignment;
/// <summary> /// <summary>
/// Creates a new paragraph with the given settings. /// Creates a new paragraph with the given settings.
@ -128,10 +141,10 @@ namespace MLEM.Ui.Elements {
/// <inheritdoc /> /// <inheritdoc />
public override void Draw(GameTime time, SpriteBatch batch, float alpha, BlendState blendState, SamplerState samplerState, Matrix matrix) { public override void Draw(GameTime time, SpriteBatch batch, float alpha, BlendState blendState, SamplerState samplerState, Matrix matrix) {
var pos = this.DisplayArea.Location; var pos = this.DisplayArea.Location + new Vector2(GetAlignmentOffset(), 0);
var sc = this.TextScale * this.TextScaleMultiplier * this.Scale; var sc = this.TextScale * this.TextScaleMultiplier * this.Scale;
var color = this.TextColor.OrDefault(Color.White) * alpha; var color = this.TextColor.OrDefault(Color.White) * alpha;
this.TokenizedText.Draw(time, batch, pos, this.RegularFont, color, sc, 0); this.TokenizedText.Draw(time, batch, pos, this.RegularFont, color, sc, 0, this.Alignment);
base.Draw(time, batch, alpha, blendState, samplerState, matrix); base.Draw(time, batch, alpha, blendState, samplerState, matrix);
} }
@ -151,7 +164,7 @@ namespace MLEM.Ui.Elements {
protected virtual void ParseText(Vector2 size) { protected virtual void ParseText(Vector2 size) {
if (this.TokenizedText == null) { if (this.TokenizedText == null) {
// tokenize the text // tokenize the text
this.TokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text); this.TokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text, this.Alignment);
// add links to the paragraph // add links to the paragraph
this.RemoveChildren(c => c is Link); this.RemoveChildren(c => c is Link);
@ -162,9 +175,9 @@ namespace MLEM.Ui.Elements {
var width = size.X - this.ScaledPadding.Width; var width = size.X - this.ScaledPadding.Width;
var scale = this.TextScale * this.TextScaleMultiplier * this.Scale; var scale = this.TextScale * this.TextScaleMultiplier * this.Scale;
if (this.TruncateIfLong) { if (this.TruncateIfLong) {
this.TokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis); this.TokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis, this.Alignment);
} else { } else {
this.TokenizedText.Split(this.RegularFont, width, scale); this.TokenizedText.Split(this.RegularFont, width, scale, this.Alignment);
} }
} }
@ -173,6 +186,16 @@ namespace MLEM.Ui.Elements {
this.Text = this.GetTextCallback(this); this.Text = this.GetTextCallback(this);
} }
private float GetAlignmentOffset() {
switch (this.Alignment) {
case TextAlignment.Center:
return this.DisplayArea.Width / 2;
case TextAlignment.Right:
return this.DisplayArea.Width;
}
return 0;
}
/// <summary> /// <summary>
/// A delegate method used for <see cref="Paragraph.GetTextCallback"/> /// A delegate method used for <see cref="Paragraph.GetTextCallback"/>
/// </summary> /// </summary>
@ -214,7 +237,7 @@ namespace MLEM.Ui.Elements {
public override void ForceUpdateArea() { public override void ForceUpdateArea() {
// set the position offset and size to the token's first area // set the position offset and size to the token's first area
var area = this.Token.GetArea(Vector2.Zero, this.textScale).First(); var area = this.Token.GetArea(Vector2.Zero, this.textScale).First();
this.PositionOffset = area.Location; this.PositionOffset = area.Location + new Vector2(((Paragraph) this.Parent).GetAlignmentOffset() / this.Parent.Scale, 0);
this.Size = area.Size; this.Size = area.Size;
base.ForceUpdateArea(); base.ForceUpdateArea();
} }

View file

@ -70,13 +70,13 @@ namespace MLEM.Font {
} }
///<inheritdoc cref="SpriteFont.MeasureString(string)"/> ///<inheritdoc cref="SpriteFont.MeasureString(string)"/>
public Vector2 MeasureString(string text) { public Vector2 MeasureString(string text, bool ignoreTrailingSpaces = false) {
var size = Vector2.Zero; var size = Vector2.Zero;
if (text.Length <= 0) if (text.Length <= 0)
return size; return size;
var xOffset = 0F; var xOffset = 0F;
foreach (var c in text) { for (var i = 0; i < text.Length; i++) {
switch (c) { switch (text[i]) {
case '\n': case '\n':
xOffset = 0; xOffset = 0;
size.Y += this.LineHeight; size.Y += this.LineHeight;
@ -90,8 +90,16 @@ namespace MLEM.Font {
case Zwsp: case Zwsp:
// don't add width for a zero-width space // don't add width for a zero-width space
break; break;
case ' ':
if (ignoreTrailingSpaces && IsTrailingSpace(text, i)) {
// if this is a trailing space, we can skip remaining spaces too
i = text.Length - 1;
break;
}
xOffset += this.MeasureChar(' ').X;
break;
default: default:
xOffset += this.MeasureChar(c).X; xOffset += this.MeasureChar(text[i]).X;
break; break;
} }
// increase x size if this line is the longest // increase x size if this line is the longest
@ -186,5 +194,13 @@ namespace MLEM.Font {
return ret.ToString(); return ret.ToString();
} }
private static bool IsTrailingSpace(string s, int index) {
for (var i = index + 1; i < s.Length; i++) {
if (s[i] != ' ')
return false;
}
return true;
}
} }
} }

View file

@ -0,0 +1,22 @@
namespace MLEM.Formatting {
/// <summary>
/// An enumeration that represents a set of alignment options for <see cref="TokenizedString"/> objects and MLEM.Ui paragraphs.
/// </summary>
public enum TextAlignment {
/// <summary>
/// Left alignment, which is also the default value
/// </summary>
Left,
/// <summary>
/// Center alignment
/// </summary>
Center,
/// <summary>
/// Right alignment.
/// In this alignment option, trailing spaces are ignored to ensure that visual alignment is consistent.
/// </summary>
Right
}
}

View file

@ -67,8 +67,9 @@ namespace MLEM.Formatting {
/// </summary> /// </summary>
/// <param name="font">The font to use for tokenization. Note that this font needs to be the same that will later be used for splitting, measuring and/or drawing.</param> /// <param name="font">The font to use for tokenization. Note that this font needs to be the same that will later be used for splitting, measuring and/or drawing.</param>
/// <param name="s">The string to tokenize</param> /// <param name="s">The string to tokenize</param>
/// <param name="alignment">The text alignment that should be used. Note that this alignment needs to be the same that will later be used for splitting, measuring and/or drawing.</param>
/// <returns></returns> /// <returns></returns>
public TokenizedString Tokenize(GenericFont font, string s) { public TokenizedString Tokenize(GenericFont font, string s, TextAlignment alignment = TextAlignment.Left) {
// resolve macros // resolve macros
s = this.ResolveMacros(s); s = this.ResolveMacros(s);
var tokens = new List<Token>(); var tokens = new List<Token>();
@ -101,7 +102,7 @@ namespace MLEM.Formatting {
codes.RemoveAll(c => c.EndsHere(next)); codes.RemoveAll(c => c.EndsHere(next));
codes.Add(next); codes.Add(next);
} }
return new TokenizedString(font, s, StripFormatting(font, s, tokens.SelectMany(t => t.AppliedCodes)), tokens.ToArray()); return new TokenizedString(font, alignment, s, StripFormatting(font, s, tokens.SelectMany(t => t.AppliedCodes)), tokens.ToArray());
} }
/// <summary> /// <summary>

View file

@ -33,6 +33,10 @@ namespace MLEM.Formatting {
/// </summary> /// </summary>
public string DisplayString => this.ModifiedSubstring ?? this.Substring; public string DisplayString => this.ModifiedSubstring ?? this.Substring;
/// <summary> /// <summary>
/// The <see cref="DisplayString"/>, but split at newline characters
/// </summary>
public string[] SplitDisplayString { get; internal set; }
/// <summary>
/// The substring that this token contains, without the formatting codes removed. /// The substring that this token contains, without the formatting codes removed.
/// </summary> /// </summary>
public readonly string RawSubstring; public readonly string RawSubstring;

View file

@ -38,13 +38,13 @@ namespace MLEM.Formatting {
public readonly Code[] AllCodes; public readonly Code[] AllCodes;
private string modifiedString; private string modifiedString;
internal TokenizedString(GenericFont font, string rawString, string strg, Token[] tokens) { internal TokenizedString(GenericFont font, TextAlignment alignment, string rawString, string strg, Token[] tokens) {
this.RawString = rawString; this.RawString = rawString;
this.String = strg; this.String = strg;
this.Tokens = tokens; this.Tokens = tokens;
// since a code can be present in multiple tokens, we use Distinct here // since a code can be present in multiple tokens, we use Distinct here
this.AllCodes = tokens.SelectMany(t => t.AppliedCodes).Distinct().ToArray(); this.AllCodes = tokens.SelectMany(t => t.AppliedCodes).Distinct().ToArray();
this.CalculateTokenAreas(font); this.RecalculateTokenData(font, alignment);
} }
/// <summary> /// <summary>
@ -55,10 +55,11 @@ namespace MLEM.Formatting {
/// <param name="font">The font to use for width calculations</param> /// <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="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="scale">The scale to use for width measurements</param>
public void Split(GenericFont font, float width, float scale) { /// <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 // a split string has the same character count as the input string but with newline characters added
this.modifiedString = font.SplitString(this.String, width, scale); this.modifiedString = font.SplitString(this.String, width, scale);
this.StoreModifiedSubstrings(font); this.StoreModifiedSubstrings(font, alignment);
} }
/// <summary> /// <summary>
@ -70,12 +71,13 @@ namespace MLEM.Formatting {
/// <param name="width">The maximum width, in display pixels based on the font and scale</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="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="ellipsis">The characters to add to the end of the string if it is too long</param>
public void Truncate(GenericFont font, float width, float scale, string ellipsis = "") { /// <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(this.String, width, scale, false, ellipsis); this.modifiedString = font.TruncateString(this.String, width, scale, false, ellipsis);
this.StoreModifiedSubstrings(font); this.StoreModifiedSubstrings(font, alignment);
} }
/// <inheritdoc cref="GenericFont.MeasureString(string)"/> /// <inheritdoc cref="GenericFont.MeasureString(string,bool)"/>
public Vector2 Measure(GenericFont font) { public Vector2 Measure(GenericFont font) {
return font.MeasureString(this.DisplayString); return font.MeasureString(this.DisplayString);
} }
@ -102,77 +104,116 @@ namespace MLEM.Formatting {
} }
/// <inheritdoc cref="GenericFont.DrawString(SpriteBatch,string,Vector2,Color,float,Vector2,float,SpriteEffects,float)"/> /// <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) { public void Draw(GameTime time, SpriteBatch batch, Vector2 pos, GenericFont font, Color color, float scale, float depth, TextAlignment alignment = TextAlignment.Left) {
var innerOffset = new Vector2(); var innerOffset = new Vector2(this.GetInnerOffsetX(font, 0, 0, scale, alignment), 0);
foreach (var token in this.Tokens) { for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var drawFont = token.GetFont(font) ?? font; var drawFont = token.GetFont(font) ?? font;
var drawColor = token.GetColor(color) ?? color; var drawColor = token.GetColor(color) ?? color;
for (var i = 0; i < token.DisplayString.Length; i++) { for (var l = 0; l < token.SplitDisplayString.Length; l++) {
var c = token.DisplayString[i]; var line = token.SplitDisplayString[l];
if (c == '\n') { for (var i = 0; i < line.Length; i++) {
innerOffset.X = 0; var c = line[i];
if (i == 0)
token.DrawSelf(time, batch, pos + innerOffset, font, color, scale, depth);
var cString = c.ToCachedString();
token.DrawCharacter(time, batch, c, cString, i, pos + innerOffset, drawFont, drawColor, scale, depth);
innerOffset.X += font.MeasureString(cString).X * scale;
}
// only split at a new line, not between tokens!
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = this.GetInnerOffsetX(font, t, l + 1, scale, alignment);
innerOffset.Y += font.LineHeight * scale; innerOffset.Y += font.LineHeight * scale;
} }
if (i == 0)
token.DrawSelf(time, batch, pos + innerOffset, font, color, scale, depth);
var cString = c.ToCachedString();
token.DrawCharacter(time, batch, c, cString, i, pos + innerOffset, drawFont, drawColor, scale, depth);
innerOffset.X += font.MeasureString(cString).X * scale;
} }
} }
} }
private void StoreModifiedSubstrings(GenericFont font) { private void StoreModifiedSubstrings(GenericFont font, TextAlignment alignment) {
// skip substring logic for unformatted text
if (this.Tokens.Length == 1) { if (this.Tokens.Length == 1) {
// skip substring logic for unformatted text
this.Tokens[0].ModifiedSubstring = this.modifiedString; this.Tokens[0].ModifiedSubstring = this.modifiedString;
return; } 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 is basically a substring function that ignores added newlines for indexing this.RecalculateTokenData(font, alignment);
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.CalculateTokenAreas(font);
} }
private void CalculateTokenAreas(GenericFont font) { private float GetInnerOffsetX(GenericFont font, int tokenIndex, int lineIndex, float scale, TextAlignment alignment) {
var innerOffset = new Vector2(); if (alignment > TextAlignment.Left) {
foreach (var token in this.Tokens) { var rest = this.GetRestOfLineLength(font, tokenIndex, lineIndex) * scale;
if (alignment == TextAlignment.Center)
rest /= 2;
return -rest;
}
return 0;
}
private float GetRestOfLineLength(GenericFont font, int tokenIndex, int lineIndex) {
var token = this.Tokens[tokenIndex];
var ret = font.MeasureString(token.SplitDisplayString[lineIndex], true).X;
if (lineIndex >= token.SplitDisplayString.Length - 1) {
// the line ends somewhere in or after the next token
for (var i = tokenIndex + 1; i < this.Tokens.Length; i++) {
var other = this.Tokens[i];
if (other.SplitDisplayString.Length > 1) {
// the line ends in this token
ret += font.MeasureString(other.SplitDisplayString[0]).X;
break;
} else {
// the line doesn't end in this token, so add it fully
ret += font.MeasureString(other.DisplayString).X;
}
}
}
return ret;
}
private void RecalculateTokenData(GenericFont font, TextAlignment alignment) {
// split display strings
foreach (var token in this.Tokens)
token.SplitDisplayString = token.DisplayString.Split('\n');
// token areas
var innerOffset = new Vector2(this.GetInnerOffsetX(font, 0, 0, 1, alignment), 0);
for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var area = new List<RectangleF>(); var area = new List<RectangleF>();
var split = token.DisplayString.Split('\n'); for (var l = 0; l < token.SplitDisplayString.Length; l++) {
for (var i = 0; i < split.Length; i++) { var size = font.MeasureString(token.SplitDisplayString[l]);
var size = font.MeasureString(split[i]);
var rect = new RectangleF(innerOffset, size); var rect = new RectangleF(innerOffset, size);
if (!rect.IsEmpty) if (!rect.IsEmpty)
area.Add(rect); area.Add(rect);
if (i < split.Length - 1) { if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = 0; innerOffset.X = this.GetInnerOffsetX(font, t, l + 1, 1, alignment);
innerOffset.Y += font.LineHeight; innerOffset.Y += font.LineHeight;
} else { } else {
innerOffset.X += size.X; innerOffset.X += size.X;